@xmoxmo/bncr 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/channel.ts CHANGED
@@ -1,2252 +1,2521 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { createHash, randomUUID } from 'node:crypto';
4
- import type {
5
- OpenClawPluginApi,
6
- OpenClawPluginServiceContext,
7
- GatewayRequestHandlerOptions,
8
- ChatType,
9
- } from 'openclaw/plugin-sdk';
10
- import {
11
- createDefaultChannelRuntimeState,
12
- setAccountEnabledInConfigSection,
13
- applyAccountNameToChannelSection,
14
- writeJsonFileAtomically,
15
- readJsonFileWithFallback,
16
- } from 'openclaw/plugin-sdk';
17
- import { CHANNEL_ID, BNCR_DEFAULT_ACCOUNT_ID, normalizeAccountId, resolveDefaultDisplayName, resolveAccount, listAccountIds } from './core/accounts.js';
18
- import type { BncrRoute, BncrConnection, OutboxEntry } from './core/types.js';
19
- import {
20
- parseRouteFromScope,
21
- parseRouteFromDisplayScope,
22
- formatDisplayScope,
23
- isLowerHex,
24
- routeScopeToHex,
25
- parseRouteFromHexScope,
26
- parseRouteLike,
27
- parseLegacySessionKeyToStrict,
28
- normalizeStoredSessionKey,
29
- parseStrictBncrSessionKey,
30
- normalizeInboundSessionKey,
31
- withTaskSessionKey,
32
- buildFallbackSessionKey,
33
- routeKey,
34
- } from './core/targets.js';
35
- import { parseBncrInboundParams } from './messaging/inbound/parse.js';
36
- import { dispatchBncrInbound } from './messaging/inbound/dispatch.js';
37
- import { checkBncrMessageGate } from './messaging/inbound/gate.js';
38
- import { sendBncrText, sendBncrMedia } from './messaging/outbound/send.js';
39
- import { buildBncrMediaOutboundFrame, resolveBncrOutboundMessageType } from './messaging/outbound/media.js';
40
- import { sendBncrReplyAction, deleteBncrMessageAction, reactBncrMessageAction, editBncrMessageAction } from './messaging/outbound/actions.js';
41
- import {
42
- buildIntegratedDiagnostics as buildIntegratedDiagnosticsFromRuntime,
43
- buildStatusHeadlineFromRuntime,
44
- buildStatusMetaFromRuntime,
45
- buildAccountRuntimeSnapshot,
46
- } from './core/status.js';
47
- import { probeBncrAccount } from './core/probe.js';
48
- import { BncrConfigSchema } from './core/config-schema.js';
49
- import { resolveBncrChannelPolicy } from './core/policy.js';
50
- import { buildBncrPermissionSummary } from './core/permissions.js';
51
- const BRIDGE_VERSION = 2;
52
- const BNCR_PUSH_EVENT = 'bncr.push';
53
- const CONNECT_TTL_MS = 120_000;
54
- const MAX_RETRY = 10;
55
- const PUSH_DRAIN_INTERVAL_MS = 500;
56
- const FILE_FORCE_CHUNK = true; // 统一走 WS 分块,保留 base64 仅作兜底
57
- const FILE_INLINE_THRESHOLD = 5 * 1024 * 1024; // fallback 阈值(仅 FILE_FORCE_CHUNK=false 时生效)
58
- const FILE_CHUNK_SIZE = 256 * 1024; // 256KB
59
- const FILE_CHUNK_RETRY = 3;
60
- const FILE_ACK_TIMEOUT_MS = 30_000;
61
- const FILE_TRANSFER_ACK_TTL_MS = 30_000;
62
- const FILE_TRANSFER_KEEP_MS = 6 * 60 * 60 * 1000;
63
- const BNCR_DEBUG_VERBOSE = true; // 临时调试:打印发送入口完整请求体
64
-
65
- type FileSendTransferState = {
66
- transferId: string;
67
- accountId: string;
68
- sessionKey: string;
69
- route: BncrRoute;
70
- fileName: string;
71
- mimeType: string;
72
- fileSize: number;
73
- chunkSize: number;
74
- totalChunks: number;
75
- fileSha256: string;
76
- startedAt: number;
77
- status: 'init' | 'transferring' | 'completed' | 'aborted';
78
- ackedChunks: Set<number>;
79
- failedChunks: Map<number, string>;
80
- completedPath?: string;
81
- error?: string;
82
- };
83
-
84
- type FileRecvTransferState = {
85
- transferId: string;
86
- accountId: string;
87
- sessionKey: string;
88
- route: BncrRoute;
89
- fileName: string;
90
- mimeType: string;
91
- fileSize: number;
92
- chunkSize: number;
93
- totalChunks: number;
94
- fileSha256: string;
95
- startedAt: number;
96
- status: 'init' | 'transferring' | 'completed' | 'aborted';
97
- bufferByChunk: Map<number, Buffer>;
98
- receivedChunks: Set<number>;
99
- completedPath?: string;
100
- error?: string;
101
- };
102
-
103
- type PersistedState = {
104
- outbox: OutboxEntry[];
105
- deadLetter: OutboxEntry[];
106
- sessionRoutes: Array<{
107
- sessionKey: string;
108
- accountId: string;
109
- route: BncrRoute;
110
- updatedAt: number;
111
- }>;
112
- lastSessionByAccount?: Array<{
113
- accountId: string;
114
- sessionKey: string;
115
- scope: string;
116
- updatedAt: number;
117
- }>;
118
- lastActivityByAccount?: Array<{
119
- accountId: string;
120
- updatedAt: number;
121
- }>;
122
- lastInboundByAccount?: Array<{
123
- accountId: string;
124
- updatedAt: number;
125
- }>;
126
- lastOutboundByAccount?: Array<{
127
- accountId: string;
128
- updatedAt: number;
129
- }>;
130
- };
131
-
132
- function now() {
133
- return Date.now();
134
- }
135
-
136
- function asString(v: unknown, fallback = ''): string {
137
- if (typeof v === 'string') return v;
138
- if (v == null) return fallback;
139
- return String(v);
140
- }
141
-
142
-
143
- function backoffMs(retryCount: number): number {
144
- // 1s,2s,4s,8s... capped by retry count checks
145
- return Math.max(1_000, 1_000 * 2 ** Math.max(0, retryCount - 1));
146
- }
147
-
148
-
149
- function fileExtFromMime(mimeType?: string): string {
150
- const mt = asString(mimeType || '').toLowerCase();
151
- const map: Record<string, string> = {
152
- 'image/jpeg': '.jpg',
153
- 'image/jpg': '.jpg',
154
- 'image/png': '.png',
155
- 'image/webp': '.webp',
156
- 'image/gif': '.gif',
157
- 'video/mp4': '.mp4',
158
- 'video/webm': '.webm',
159
- 'video/quicktime': '.mov',
160
- 'audio/mpeg': '.mp3',
161
- 'audio/mp4': '.m4a',
162
- 'application/pdf': '.pdf',
163
- 'text/plain': '.txt',
164
- };
165
- return map[mt] || '';
166
- }
167
-
168
- function sanitizeFileName(rawName?: string, fallback = 'file.bin'): string {
169
- const name = asString(rawName || '').trim();
170
- const base = name || fallback;
171
- const cleaned = base.replace(/[\\/:*?"<>|\x00-\x1F]+/g, '_').replace(/\s+/g, ' ').trim();
172
- return cleaned || fallback;
173
- }
174
-
175
- function buildTimestampFileName(mimeType?: string): string {
176
- const d = new Date();
177
- const pad = (n: number) => String(n).padStart(2, '0');
178
- const ts = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
179
- const ext = fileExtFromMime(mimeType) || '.bin';
180
- return `bncr_${ts}_${Math.random().toString(16).slice(2, 8)}${ext}`;
181
- }
182
-
183
- function resolveOutboundFileName(params: { mediaUrl?: string; fileName?: string; mimeType?: string }): string {
184
- const mediaUrl = asString(params.mediaUrl || '').trim();
185
- const mimeType = asString(params.mimeType || '').trim();
186
-
187
- // 线上下载的文件,统一用时间戳命名(避免超长/无意义文件名)
188
- if (/^https?:\/\//i.test(mediaUrl)) {
189
- return buildTimestampFileName(mimeType);
190
- }
191
-
192
- const candidate = sanitizeFileName(params.fileName, 'file.bin');
193
- if (candidate.length <= 80) return candidate;
194
-
195
- // 超长文件名做裁剪,尽量保留扩展名
196
- const ext = path.extname(candidate);
197
- const stem = candidate.slice(0, Math.max(1, 80 - ext.length));
198
- return `${stem}${ext}`;
199
- }
200
-
201
- class BncrBridgeRuntime {
202
- private api: OpenClawPluginApi;
203
- private statePath: string | null = null;
204
-
205
- private connections = new Map<string, BncrConnection>(); // connectionKey -> connection
206
- private activeConnectionByAccount = new Map<string, string>(); // accountId -> connectionKey
207
- private outbox = new Map<string, OutboxEntry>(); // messageId -> entry
208
- private deadLetter: OutboxEntry[] = [];
209
-
210
- private sessionRoutes = new Map<string, { accountId: string; route: BncrRoute; updatedAt: number }>();
211
- private routeAliases = new Map<string, { accountId: string; route: BncrRoute; updatedAt: number }>();
212
-
213
- private recentInbound = new Map<string, number>();
214
- private lastSessionByAccount = new Map<string, { sessionKey: string; scope: string; updatedAt: number }>();
215
- private lastActivityByAccount = new Map<string, number>();
216
- private lastInboundByAccount = new Map<string, number>();
217
- private lastOutboundByAccount = new Map<string, number>();
218
-
219
- // 内置健康/回归计数(替代独立脚本)
220
- private startedAt = now();
221
- private connectEventsByAccount = new Map<string, number>();
222
- private inboundEventsByAccount = new Map<string, number>();
223
- private activityEventsByAccount = new Map<string, number>();
224
- private ackEventsByAccount = new Map<string, number>();
225
-
226
- private saveTimer: NodeJS.Timeout | null = null;
227
- private pushTimer: NodeJS.Timeout | null = null;
228
- private pushDrainRunningAccounts = new Set<string>();
229
- private waiters = new Map<string, Array<() => void>>();
230
- private gatewayContext: GatewayRequestHandlerOptions['context'] | null = null;
231
-
232
- // 文件互传状态(V1:尽力而为,重连不续传)
233
- private fileSendTransfers = new Map<string, FileSendTransferState>(); // OpenClaw -> Bncr(服务端发起)
234
- private fileRecvTransfers = new Map<string, FileRecvTransferState>(); // Bncr -> OpenClaw(客户端发起)
235
- private fileAckWaiters = new Map<string, {
236
- resolve: (payload: Record<string, unknown>) => void;
237
- reject: (err: Error) => void;
238
- timer: NodeJS.Timeout;
239
- }>();
240
-
241
- constructor(api: OpenClawPluginApi) {
242
- this.api = api;
243
- }
244
-
245
- startService = async (ctx: OpenClawPluginServiceContext) => {
246
- this.statePath = path.join(ctx.stateDir, 'bncr-bridge-state.json');
247
- await this.loadState();
248
- const bootDiag = this.buildIntegratedDiagnostics(BNCR_DEFAULT_ACCOUNT_ID);
249
- 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})`);
250
- };
251
-
252
- stopService = async () => {
253
- if (this.pushTimer) {
254
- clearTimeout(this.pushTimer);
255
- this.pushTimer = null;
256
- }
257
- await this.flushState();
258
- this.api.logger.info('bncr-channel service stopped');
259
- };
260
-
261
- private scheduleSave() {
262
- if (this.saveTimer) return;
263
- this.saveTimer = setTimeout(() => {
264
- this.saveTimer = null;
265
- void this.flushState();
266
- }, 300);
267
- }
268
-
269
- private incrementCounter(map: Map<string, number>, accountId: string) {
270
- const acc = normalizeAccountId(accountId);
271
- map.set(acc, (map.get(acc) || 0) + 1);
272
- }
273
-
274
- private getCounter(map: Map<string, number>, accountId: string): number {
275
- return map.get(normalizeAccountId(accountId)) || 0;
276
- }
277
-
278
- private countInvalidOutboxSessionKeys(accountId: string): number {
279
- const acc = normalizeAccountId(accountId);
280
- let count = 0;
281
- for (const entry of this.outbox.values()) {
282
- if (entry.accountId !== acc) continue;
283
- if (!parseStrictBncrSessionKey(entry.sessionKey)) count += 1;
284
- }
285
- return count;
286
- }
287
-
288
- private countLegacyAccountResidue(accountId: string): number {
289
- const acc = normalizeAccountId(accountId);
290
- const mismatched = (raw?: string | null) => asString(raw || '').trim() && normalizeAccountId(raw) !== acc;
291
-
292
- let count = 0;
293
-
294
- for (const entry of this.outbox.values()) {
295
- if (mismatched(entry.accountId)) count += 1;
296
- }
297
- for (const entry of this.deadLetter) {
298
- if (mismatched(entry.accountId)) count += 1;
299
- }
300
- for (const info of this.sessionRoutes.values()) {
301
- if (mismatched(info.accountId)) count += 1;
302
- }
303
- for (const key of this.lastSessionByAccount.keys()) {
304
- if (mismatched(key)) count += 1;
305
- }
306
- for (const key of this.lastActivityByAccount.keys()) {
307
- if (mismatched(key)) count += 1;
308
- }
309
- for (const key of this.lastInboundByAccount.keys()) {
310
- if (mismatched(key)) count += 1;
311
- }
312
- for (const key of this.lastOutboundByAccount.keys()) {
313
- if (mismatched(key)) count += 1;
314
- }
315
-
316
- return count;
317
- }
318
-
319
- private buildIntegratedDiagnostics(accountId: string) {
320
- const acc = normalizeAccountId(accountId);
321
- return buildIntegratedDiagnosticsFromRuntime({
322
- accountId: acc,
323
- connected: this.isOnline(acc),
324
- pending: Array.from(this.outbox.values()).filter((v) => v.accountId === acc).length,
325
- deadLetter: this.deadLetter.filter((v) => v.accountId === acc).length,
326
- activeConnections: this.activeConnectionCount(acc),
327
- connectEvents: this.getCounter(this.connectEventsByAccount, acc),
328
- inboundEvents: this.getCounter(this.inboundEventsByAccount, acc),
329
- activityEvents: this.getCounter(this.activityEventsByAccount, acc),
330
- ackEvents: this.getCounter(this.ackEventsByAccount, acc),
331
- startedAt: this.startedAt,
332
- lastSession: this.lastSessionByAccount.get(acc) || null,
333
- lastActivityAt: this.lastActivityByAccount.get(acc) || null,
334
- lastInboundAt: this.lastInboundByAccount.get(acc) || null,
335
- lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
336
- sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc).length,
337
- invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
338
- legacyAccountResidue: this.countLegacyAccountResidue(acc),
339
- channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
340
- });
341
- }
342
-
343
- private async loadState() {
344
- if (!this.statePath) return;
345
- const loaded = await readJsonFileWithFallback(this.statePath, {
346
- outbox: [],
347
- deadLetter: [],
348
- sessionRoutes: [],
349
- });
350
- const data = loaded.value as PersistedState;
351
-
352
- this.outbox.clear();
353
- for (const entry of data.outbox || []) {
354
- if (!entry?.messageId) continue;
355
- const accountId = normalizeAccountId(entry.accountId);
356
- const sessionKey = asString(entry.sessionKey || '').trim();
357
- const normalized = normalizeStoredSessionKey(sessionKey);
358
- if (!normalized) continue;
359
-
360
- const route = parseRouteLike(entry.route) || normalized.route;
361
- const payload = (entry.payload && typeof entry.payload === 'object') ? { ...entry.payload } : {};
362
- (payload as any).sessionKey = normalized.sessionKey;
363
- (payload as any).platform = route.platform;
364
- (payload as any).groupId = route.groupId;
365
- (payload as any).userId = route.userId;
366
-
367
- const migratedEntry: OutboxEntry = {
368
- ...entry,
369
- accountId,
370
- sessionKey: normalized.sessionKey,
371
- route,
372
- payload,
373
- createdAt: Number(entry.createdAt || now()),
374
- retryCount: Number(entry.retryCount || 0),
375
- nextAttemptAt: Number(entry.nextAttemptAt || now()),
376
- lastAttemptAt: entry.lastAttemptAt ? Number(entry.lastAttemptAt) : undefined,
377
- lastError: entry.lastError ? asString(entry.lastError) : undefined,
378
- };
379
-
380
- this.outbox.set(migratedEntry.messageId, migratedEntry);
381
- }
382
-
383
- this.deadLetter = [];
384
- for (const entry of Array.isArray(data.deadLetter) ? data.deadLetter : []) {
385
- if (!entry?.messageId) continue;
386
- const accountId = normalizeAccountId(entry.accountId);
387
- const sessionKey = asString(entry.sessionKey || '').trim();
388
- const normalized = normalizeStoredSessionKey(sessionKey);
389
- if (!normalized) continue;
390
-
391
- const route = parseRouteLike(entry.route) || normalized.route;
392
- const payload = (entry.payload && typeof entry.payload === 'object') ? { ...entry.payload } : {};
393
- (payload as any).sessionKey = normalized.sessionKey;
394
- (payload as any).platform = route.platform;
395
- (payload as any).groupId = route.groupId;
396
- (payload as any).userId = route.userId;
397
-
398
- this.deadLetter.push({
399
- ...entry,
400
- accountId,
401
- sessionKey: normalized.sessionKey,
402
- route,
403
- payload,
404
- createdAt: Number(entry.createdAt || now()),
405
- retryCount: Number(entry.retryCount || 0),
406
- nextAttemptAt: Number(entry.nextAttemptAt || now()),
407
- lastAttemptAt: entry.lastAttemptAt ? Number(entry.lastAttemptAt) : undefined,
408
- lastError: entry.lastError ? asString(entry.lastError) : undefined,
409
- });
410
- }
411
-
412
- this.sessionRoutes.clear();
413
- this.routeAliases.clear();
414
- for (const item of data.sessionRoutes || []) {
415
- const normalized = normalizeStoredSessionKey(asString(item?.sessionKey || ''));
416
- if (!normalized) continue;
417
-
418
- const route = parseRouteLike(item?.route) || normalized.route;
419
- const accountId = normalizeAccountId(item?.accountId);
420
- const updatedAt = Number(item?.updatedAt || now());
421
-
422
- const info = {
423
- accountId,
424
- route,
425
- updatedAt,
426
- };
427
-
428
- this.sessionRoutes.set(normalized.sessionKey, info);
429
- this.routeAliases.set(routeKey(accountId, route), info);
430
- }
431
-
432
- this.lastSessionByAccount.clear();
433
- for (const item of data.lastSessionByAccount || []) {
434
- const accountId = normalizeAccountId(item?.accountId);
435
- const normalized = normalizeStoredSessionKey(asString(item?.sessionKey || ''));
436
- const updatedAt = Number(item?.updatedAt || 0);
437
- if (!normalized || !Number.isFinite(updatedAt) || updatedAt <= 0) continue;
438
-
439
- this.lastSessionByAccount.set(accountId, {
440
- sessionKey: normalized.sessionKey,
441
- // 展示统一为 Bncr-platform:group:user
442
- scope: formatDisplayScope(normalized.route),
443
- updatedAt,
444
- });
445
- }
446
-
447
- this.lastActivityByAccount.clear();
448
- for (const item of data.lastActivityByAccount || []) {
449
- const accountId = normalizeAccountId(item?.accountId);
450
- const updatedAt = Number(item?.updatedAt || 0);
451
- if (!Number.isFinite(updatedAt) || updatedAt <= 0) continue;
452
- this.lastActivityByAccount.set(accountId, updatedAt);
453
- }
454
-
455
- this.lastInboundByAccount.clear();
456
- for (const item of data.lastInboundByAccount || []) {
457
- const accountId = normalizeAccountId(item?.accountId);
458
- const updatedAt = Number(item?.updatedAt || 0);
459
- if (!Number.isFinite(updatedAt) || updatedAt <= 0) continue;
460
- this.lastInboundByAccount.set(accountId, updatedAt);
461
- }
462
-
463
- this.lastOutboundByAccount.clear();
464
- for (const item of data.lastOutboundByAccount || []) {
465
- const accountId = normalizeAccountId(item?.accountId);
466
- const updatedAt = Number(item?.updatedAt || 0);
467
- if (!Number.isFinite(updatedAt) || updatedAt <= 0) continue;
468
- this.lastOutboundByAccount.set(accountId, updatedAt);
469
- }
470
-
471
- // 兼容旧状态文件:若尚未持久化 lastSession*/lastActivity*,从 sessionRoutes 回填。
472
- if (this.lastSessionByAccount.size === 0 && this.sessionRoutes.size > 0) {
473
- for (const [sessionKey, info] of this.sessionRoutes.entries()) {
474
- const acc = normalizeAccountId(info.accountId);
475
- const updatedAt = Number(info.updatedAt || 0);
476
- if (!Number.isFinite(updatedAt) || updatedAt <= 0) continue;
477
-
478
- const current = this.lastSessionByAccount.get(acc);
479
- if (!current || updatedAt >= current.updatedAt) {
480
- this.lastSessionByAccount.set(acc, {
481
- sessionKey,
482
- // 回填时统一展示为 Bncr-platform:group:user
483
- scope: formatDisplayScope(info.route),
484
- updatedAt,
485
- });
486
- }
487
-
488
- const lastAct = this.lastActivityByAccount.get(acc) || 0;
489
- if (updatedAt > lastAct) this.lastActivityByAccount.set(acc, updatedAt);
490
-
491
- const lastIn = this.lastInboundByAccount.get(acc) || 0;
492
- if (updatedAt > lastIn) this.lastInboundByAccount.set(acc, updatedAt);
493
- }
494
- }
495
- }
496
-
497
- private async flushState() {
498
- if (!this.statePath) return;
499
-
500
- const sessionRoutes = Array.from(this.sessionRoutes.entries())
501
- .map(([sessionKey, v]) => ({
502
- sessionKey,
503
- accountId: v.accountId,
504
- route: v.route,
505
- updatedAt: v.updatedAt,
506
- }))
507
- .slice(-1000);
508
-
509
- const data: PersistedState = {
510
- outbox: Array.from(this.outbox.values()),
511
- deadLetter: this.deadLetter.slice(-1000),
512
- sessionRoutes,
513
- lastSessionByAccount: Array.from(this.lastSessionByAccount.entries()).map(([accountId, v]) => ({
514
- accountId,
515
- sessionKey: v.sessionKey,
516
- scope: v.scope,
517
- updatedAt: v.updatedAt,
518
- })),
519
- lastActivityByAccount: Array.from(this.lastActivityByAccount.entries()).map(([accountId, updatedAt]) => ({
520
- accountId,
521
- updatedAt,
522
- })),
523
- lastInboundByAccount: Array.from(this.lastInboundByAccount.entries()).map(([accountId, updatedAt]) => ({
524
- accountId,
525
- updatedAt,
526
- })),
527
- lastOutboundByAccount: Array.from(this.lastOutboundByAccount.entries()).map(([accountId, updatedAt]) => ({
528
- accountId,
529
- updatedAt,
530
- })),
531
- };
532
-
533
- await writeJsonFileAtomically(this.statePath, data);
534
- }
535
-
536
- private wakeAccountWaiters(accountId: string) {
537
- const key = normalizeAccountId(accountId);
538
- const waits = this.waiters.get(key);
539
- if (!waits?.length) return;
540
- this.waiters.delete(key);
541
- for (const resolve of waits) resolve();
542
- }
543
-
544
- private rememberGatewayContext(context: GatewayRequestHandlerOptions['context']) {
545
- if (context) this.gatewayContext = context;
546
- }
547
-
548
- private resolvePushConnIds(accountId: string): Set<string> {
549
- const acc = normalizeAccountId(accountId);
550
- const t = now();
551
- const connIds = new Set<string>();
552
-
553
- const primaryKey = this.activeConnectionByAccount.get(acc);
554
- if (primaryKey) {
555
- const primary = this.connections.get(primaryKey);
556
- if (primary?.connId && t - primary.lastSeenAt <= CONNECT_TTL_MS) {
557
- connIds.add(primary.connId);
558
- }
559
- }
560
-
561
- if (connIds.size > 0) return connIds;
562
-
563
- for (const c of this.connections.values()) {
564
- if (c.accountId !== acc) continue;
565
- if (!c.connId) continue;
566
- if (t - c.lastSeenAt > CONNECT_TTL_MS) continue;
567
- connIds.add(c.connId);
568
- }
569
-
570
- return connIds;
571
- }
572
-
573
- private tryPushEntry(entry: OutboxEntry): boolean {
574
- const ctx = this.gatewayContext;
575
- if (!ctx) return false;
576
-
577
- const connIds = this.resolvePushConnIds(entry.accountId);
578
- if (!connIds.size) return false;
579
-
580
- try {
581
- const payload = {
582
- ...entry.payload,
583
- idempotencyKey: entry.messageId,
584
- };
585
-
586
- ctx.broadcastToConnIds(BNCR_PUSH_EVENT, payload, connIds);
587
- this.outbox.delete(entry.messageId);
588
- this.lastOutboundByAccount.set(entry.accountId, now());
589
- this.markActivity(entry.accountId);
590
- this.scheduleSave();
591
- return true;
592
- } catch (error) {
593
- entry.lastError = asString((error as any)?.message || error || 'push-error');
594
- this.outbox.set(entry.messageId, entry);
595
- return false;
596
- }
597
- }
598
-
599
- private schedulePushDrain(delayMs = 0) {
600
- if (this.pushTimer) return;
601
- const delay = Math.max(0, Math.min(Number(delayMs || 0), 30_000));
602
- this.pushTimer = setTimeout(() => {
603
- this.pushTimer = null;
604
- void this.flushPushQueue();
605
- }, delay);
606
- }
607
-
608
- private async flushPushQueue(accountId?: string): Promise<void> {
609
- const filterAcc = accountId ? normalizeAccountId(accountId) : null;
610
- const targetAccounts = filterAcc
611
- ? [filterAcc]
612
- : Array.from(new Set(Array.from(this.outbox.values()).map((entry) => normalizeAccountId(entry.accountId))));
613
-
614
- let globalNextDelay: number | null = null;
615
-
616
- for (const acc of targetAccounts) {
617
- if (!acc || this.pushDrainRunningAccounts.has(acc)) continue;
618
- if (!this.isOnline(acc)) continue;
619
-
620
- this.pushDrainRunningAccounts.add(acc);
621
- try {
622
- let localNextDelay: number | null = null;
623
-
624
- while (true) {
625
- const t = now();
626
- const entries = Array.from(this.outbox.values())
627
- .filter((entry) => normalizeAccountId(entry.accountId) === acc)
628
- .sort((a, b) => a.createdAt - b.createdAt);
629
-
630
- if (!entries.length) break;
631
- if (!this.isOnline(acc)) break;
632
-
633
- const entry = entries.find((item) => item.nextAttemptAt <= t);
634
- if (!entry) {
635
- const wait = Math.max(0, entries[0].nextAttemptAt - t);
636
- localNextDelay = localNextDelay == null ? wait : Math.min(localNextDelay, wait);
637
- break;
638
- }
639
-
640
- const pushed = this.tryPushEntry(entry);
641
- if (pushed) {
642
- this.scheduleSave();
643
- await this.sleepMs(PUSH_DRAIN_INTERVAL_MS);
644
- continue;
645
- }
646
-
647
- const nextAttempt = entry.retryCount + 1;
648
- if (nextAttempt > MAX_RETRY) {
649
- this.moveToDeadLetter(entry, entry.lastError || 'push-retry-limit');
650
- continue;
651
- }
652
-
653
- entry.retryCount = nextAttempt;
654
- entry.lastAttemptAt = t;
655
- entry.nextAttemptAt = t + backoffMs(nextAttempt);
656
- entry.lastError = entry.lastError || 'push-retry';
657
- this.outbox.set(entry.messageId, entry);
658
- this.scheduleSave();
659
-
660
- const wait = Math.max(0, entry.nextAttemptAt - t);
661
- localNextDelay = localNextDelay == null ? wait : Math.min(localNextDelay, wait);
662
- break;
663
- }
664
-
665
- if (localNextDelay != null) {
666
- globalNextDelay = globalNextDelay == null ? localNextDelay : Math.min(globalNextDelay, localNextDelay);
667
- }
668
- } finally {
669
- this.pushDrainRunningAccounts.delete(acc);
670
- }
671
- }
672
-
673
- if (globalNextDelay != null) this.schedulePushDrain(globalNextDelay);
674
- }
675
-
676
- private async waitForOutbound(accountId: string, waitMs: number): Promise<void> {
677
- const key = normalizeAccountId(accountId);
678
- const timeoutMs = Math.max(0, Math.min(waitMs, 25_000));
679
- if (!timeoutMs) return;
680
-
681
- await new Promise<void>((resolve) => {
682
- const timer = setTimeout(() => {
683
- const arr = this.waiters.get(key) || [];
684
- this.waiters.set(
685
- key,
686
- arr.filter((fn) => fn !== done),
687
- );
688
- resolve();
689
- }, timeoutMs);
690
-
691
- const done = () => {
692
- clearTimeout(timer);
693
- resolve();
694
- };
695
-
696
- const arr = this.waiters.get(key) || [];
697
- arr.push(done);
698
- this.waiters.set(key, arr);
699
- });
700
- }
701
-
702
- private connectionKey(accountId: string, clientId?: string): string {
703
- const acc = normalizeAccountId(accountId);
704
- const cid = asString(clientId || '').trim();
705
- return `${acc}::${cid || 'default'}`;
706
- }
707
-
708
- private gcTransientState() {
709
- const t = now();
710
-
711
- // 清理过期连接
712
- const staleBefore = t - CONNECT_TTL_MS * 2;
713
- for (const [key, c] of this.connections.entries()) {
714
- if (c.lastSeenAt < staleBefore) this.connections.delete(key);
715
- }
716
-
717
- // 清理去重窗口(90s)
718
- const dedupWindowMs = 90_000;
719
- for (const [key, ts] of this.recentInbound.entries()) {
720
- if (t - ts > dedupWindowMs) this.recentInbound.delete(key);
721
- }
722
-
723
- this.cleanupFileTransfers();
724
- }
725
-
726
- private cleanupFileTransfers() {
727
- const t = now();
728
- for (const [id, st] of this.fileSendTransfers.entries()) {
729
- if (t - st.startedAt > FILE_TRANSFER_KEEP_MS) this.fileSendTransfers.delete(id);
730
- }
731
- for (const [id, st] of this.fileRecvTransfers.entries()) {
732
- if (t - st.startedAt > FILE_TRANSFER_KEEP_MS) this.fileRecvTransfers.delete(id);
733
- }
734
- }
735
-
736
- private markSeen(accountId: string, connId: string, clientId?: string) {
737
- this.gcTransientState();
738
-
739
- const acc = normalizeAccountId(accountId);
740
- const key = this.connectionKey(acc, clientId);
741
- const t = now();
742
- const prev = this.connections.get(key);
743
-
744
- const nextConn: BncrConnection = {
745
- accountId: acc,
746
- connId,
747
- clientId: asString(clientId || '').trim() || undefined,
748
- connectedAt: prev?.connectedAt || t,
749
- lastSeenAt: t,
750
- };
751
-
752
- this.connections.set(key, nextConn);
753
-
754
- const current = this.activeConnectionByAccount.get(acc);
755
- if (!current) {
756
- this.activeConnectionByAccount.set(acc, key);
757
- return;
758
- }
759
-
760
- const curConn = this.connections.get(current);
761
- if (!curConn || t - curConn.lastSeenAt > CONNECT_TTL_MS || nextConn.connectedAt >= curConn.connectedAt) {
762
- this.activeConnectionByAccount.set(acc, key);
763
- }
764
- }
765
-
766
- private isOnline(accountId: string): boolean {
767
- const acc = normalizeAccountId(accountId);
768
- const t = now();
769
- for (const c of this.connections.values()) {
770
- if (c.accountId !== acc) continue;
771
- if (t - c.lastSeenAt <= CONNECT_TTL_MS) return true;
772
- }
773
- return false;
774
- }
775
-
776
- private activeConnectionCount(accountId: string): number {
777
- const acc = normalizeAccountId(accountId);
778
- const t = now();
779
- let n = 0;
780
- for (const c of this.connections.values()) {
781
- if (c.accountId !== acc) continue;
782
- if (t - c.lastSeenAt <= CONNECT_TTL_MS) n += 1;
783
- }
784
- return n;
785
- }
786
-
787
- private isPrimaryConnection(accountId: string, clientId?: string): boolean {
788
- const acc = normalizeAccountId(accountId);
789
- const key = this.connectionKey(acc, clientId);
790
- const primary = this.activeConnectionByAccount.get(acc);
791
- if (!primary) return true;
792
- return primary === key;
793
- }
794
-
795
- private markInboundDedupSeen(key: string): boolean {
796
- const t = now();
797
- const last = this.recentInbound.get(key);
798
- this.recentInbound.set(key, t);
799
-
800
- // 90s 内重复包直接丢弃
801
- return typeof last === 'number' && t - last <= 90_000;
802
- }
803
-
804
- private rememberSessionRoute(sessionKey: string, accountId: string, route: BncrRoute) {
805
- const key = asString(sessionKey).trim();
806
- if (!key) return;
807
-
808
- const acc = normalizeAccountId(accountId);
809
- const t = now();
810
- const info = { accountId: acc, route, updatedAt: t };
811
-
812
- this.sessionRoutes.set(key, info);
813
- // 同步维护旧格式与新格式,便于平滑切换
814
- this.sessionRoutes.set(buildFallbackSessionKey(route), info);
815
-
816
- this.routeAliases.set(routeKey(acc, route), info);
817
- this.lastSessionByAccount.set(acc, {
818
- sessionKey: key,
819
- // 状态展示统一为 Bncr-platform:group:user
820
- scope: formatDisplayScope(route),
821
- updatedAt: t,
822
- });
823
- this.markActivity(acc, t);
824
- this.scheduleSave();
825
- }
826
-
827
- private resolveRouteBySession(sessionKey: string, accountId: string): BncrRoute | null {
828
- const key = asString(sessionKey).trim();
829
- const hit = this.sessionRoutes.get(key);
830
- if (hit && normalizeAccountId(accountId) === normalizeAccountId(hit.accountId)) {
831
- return hit.route;
832
- }
833
-
834
- const parsed = parseStrictBncrSessionKey(key);
835
- if (!parsed) return null;
836
-
837
- const alias = this.routeAliases.get(routeKey(normalizeAccountId(accountId), parsed.route));
838
- return alias?.route || parsed.route;
839
- }
840
-
841
- // 严谨目标解析:
842
- // 1) 标准 to 仅认 Bncr:<platform>:<groupId>:<userId> / Bncr:<platform>:<userId>
843
- // 2) 仍接受 strict sessionKey 作为内部兼容输入
844
- // 3) 其他旧格式直接失败,并输出标准格式提示日志
845
- private resolveVerifiedTarget(rawTarget: string, accountId: string): { sessionKey: string; route: BncrRoute; displayScope: string } {
846
- const acc = normalizeAccountId(accountId);
847
- const raw = asString(rawTarget).trim();
848
- if (!raw) throw new Error('bncr invalid target(empty)');
849
-
850
- this.api.logger.info?.(`[bncr-target-incoming] raw=${raw} accountId=${acc}`);
851
-
852
- let route: BncrRoute | null = null;
853
-
854
- const strict = parseStrictBncrSessionKey(raw);
855
- if (strict) {
856
- route = strict.route;
857
- } else {
858
- route = parseRouteFromDisplayScope(raw) || this.resolveRouteBySession(raw, acc);
859
- }
860
-
861
- if (!route) {
862
- this.api.logger.warn?.(
863
- `[bncr-target-invalid] raw=${raw} accountId=${acc} reason=unparseable-or-unknown standardTo=Bncr:<platform>:<groupId>:<userId>|Bncr:<platform>:<userId> standardSessionKey=agent:main:bncr:direct:<hex(scope)>`,
864
- );
865
- throw new Error(`bncr invalid target(standard: Bncr:<platform>:<groupId>:<userId> | Bncr:<platform>:<userId>): ${raw}`);
866
- }
867
-
868
- const wantedRouteKey = routeKey(acc, route);
869
- let best: { sessionKey: string; route: BncrRoute; updatedAt: number } | null = null;
870
-
871
- for (const [key, info] of this.sessionRoutes.entries()) {
872
- if (normalizeAccountId(info.accountId) !== acc) continue;
873
- const parsed = parseStrictBncrSessionKey(key);
874
- if (!parsed) continue;
875
- if (routeKey(acc, parsed.route) !== wantedRouteKey) continue;
876
-
877
- const updatedAt = Number(info.updatedAt || 0);
878
- if (!best || updatedAt >= best.updatedAt) {
879
- best = {
880
- sessionKey: parsed.sessionKey,
881
- route: parsed.route,
882
- updatedAt,
883
- };
884
- }
885
- }
886
-
887
- if (!best) {
888
- this.api.logger.warn?.(`[bncr-target-miss] raw=${raw} accountId=${acc} sessionRoutes=${this.sessionRoutes.size}`);
889
- throw new Error(`bncr target not found in known sessions: ${raw}`);
890
- }
891
-
892
- // 发送链路命中目标时,同步刷新 lastSession,避免状态页显示过期会话。
893
- this.lastSessionByAccount.set(acc, {
894
- sessionKey: best.sessionKey,
895
- scope: formatDisplayScope(best.route),
896
- updatedAt: now(),
897
- });
898
- this.scheduleSave();
899
-
900
- return {
901
- sessionKey: best.sessionKey,
902
- route: best.route,
903
- displayScope: formatDisplayScope(best.route),
904
- };
905
- }
906
-
907
- private markActivity(accountId: string, at = now()) {
908
- this.lastActivityByAccount.set(normalizeAccountId(accountId), at);
909
- }
910
-
911
- private fileAckKey(transferId: string, stage: string, chunkIndex?: number): string {
912
- const idx = Number.isFinite(Number(chunkIndex)) ? String(Number(chunkIndex)) : '-';
913
- return `${transferId}|${stage}|${idx}`;
914
- }
915
-
916
- private waitForFileAck(params: { transferId: string; stage: string; chunkIndex?: number; timeoutMs?: number }) {
917
- const transferId = asString(params.transferId).trim();
918
- const stage = asString(params.stage).trim();
919
- const key = this.fileAckKey(transferId, stage, params.chunkIndex);
920
- const timeoutMs = Math.max(1_000, Math.min(Number(params.timeoutMs || FILE_ACK_TIMEOUT_MS), 120_000));
921
-
922
- return new Promise<Record<string, unknown>>((resolve, reject) => {
923
- const timer = setTimeout(() => {
924
- this.fileAckWaiters.delete(key);
925
- reject(new Error(`file ack timeout: ${key}`));
926
- }, timeoutMs);
927
- this.fileAckWaiters.set(key, { resolve, reject, timer });
928
- });
929
- }
930
-
931
- private resolveFileAck(params: { transferId: string; stage: string; chunkIndex?: number; payload: Record<string, unknown>; ok: boolean }) {
932
- const transferId = asString(params.transferId).trim();
933
- const stage = asString(params.stage).trim();
934
- const key = this.fileAckKey(transferId, stage, params.chunkIndex);
935
- const waiter = this.fileAckWaiters.get(key);
936
- if (!waiter) return false;
937
- this.fileAckWaiters.delete(key);
938
- clearTimeout(waiter.timer);
939
- if (params.ok) waiter.resolve(params.payload);
940
- else waiter.reject(new Error(asString(params.payload?.errorMessage || params.payload?.error || 'file ack failed')));
941
- return true;
942
- }
943
-
944
- private pushFileEventToAccount(accountId: string, event: string, payload: Record<string, unknown>) {
945
- const connIds = this.resolvePushConnIds(accountId);
946
- if (!connIds.size || !this.gatewayContext) {
947
- throw new Error(`no active bncr connection for account=${accountId}`);
948
- }
949
- this.gatewayContext.broadcastToConnIds(event, payload, connIds);
950
- }
951
-
952
- private resolveInboundFileType(mimeType: string, fileName: string): string {
953
- const mt = asString(mimeType).toLowerCase();
954
- const fn = asString(fileName).toLowerCase();
955
- if (mt.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(fn)) return 'image';
956
- if (mt.startsWith('video/') || /\.(mp4|mov|mkv|avi|webm)$/.test(fn)) return 'video';
957
- if (mt.startsWith('audio/') || /\.(mp3|wav|m4a|aac|ogg|flac)$/.test(fn)) return 'audio';
958
- return mt || 'file';
959
- }
960
-
961
- private resolveInboundFilesDir(): string {
962
- const dir = path.join(process.cwd(), '.openclaw', 'media', 'inbound', 'bncr');
963
- fs.mkdirSync(dir, { recursive: true });
964
- return dir;
965
- }
966
-
967
- private async materializeRecvTransfer(st: FileRecvTransferState): Promise<{ path: string; fileSha256: string }> {
968
- const dir = this.resolveInboundFilesDir();
969
- const safeName = asString(st.fileName).trim() || `${st.transferId}.bin`;
970
- const finalPath = path.join(dir, safeName);
971
-
972
- const ordered: Buffer[] = [];
973
- for (let i = 0; i < st.totalChunks; i++) {
974
- const chunk = st.bufferByChunk.get(i);
975
- if (!chunk) throw new Error(`missing chunk ${i}`);
976
- ordered.push(chunk);
977
- }
978
- const merged = Buffer.concat(ordered);
979
- if (Number(st.fileSize || 0) > 0 && merged.length !== Number(st.fileSize || 0)) {
980
- throw new Error(`size mismatch expected=${st.fileSize} got=${merged.length}`);
981
- }
982
-
983
- const sha = createHash('sha256').update(merged).digest('hex');
984
- if (st.fileSha256 && sha !== st.fileSha256) {
985
- throw new Error(`sha256 mismatch expected=${st.fileSha256} got=${sha}`);
986
- }
987
-
988
- fs.writeFileSync(finalPath, merged);
989
- return { path: finalPath, fileSha256: sha };
990
- }
991
-
992
- private buildStatusMeta(accountId: string) {
993
- const acc = normalizeAccountId(accountId);
994
- return buildStatusMetaFromRuntime({
995
- accountId: acc,
996
- connected: this.isOnline(acc),
997
- pending: Array.from(this.outbox.values()).filter((v) => v.accountId === acc).length,
998
- deadLetter: this.deadLetter.filter((v) => v.accountId === acc).length,
999
- activeConnections: this.activeConnectionCount(acc),
1000
- connectEvents: this.getCounter(this.connectEventsByAccount, acc),
1001
- inboundEvents: this.getCounter(this.inboundEventsByAccount, acc),
1002
- activityEvents: this.getCounter(this.activityEventsByAccount, acc),
1003
- ackEvents: this.getCounter(this.ackEventsByAccount, acc),
1004
- startedAt: this.startedAt,
1005
- lastSession: this.lastSessionByAccount.get(acc) || null,
1006
- lastActivityAt: this.lastActivityByAccount.get(acc) || null,
1007
- lastInboundAt: this.lastInboundByAccount.get(acc) || null,
1008
- lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
1009
- sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc).length,
1010
- invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
1011
- legacyAccountResidue: this.countLegacyAccountResidue(acc),
1012
- channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
1013
- });
1014
- }
1015
-
1016
- getAccountRuntimeSnapshot(accountId: string) {
1017
- const acc = normalizeAccountId(accountId);
1018
- return buildAccountRuntimeSnapshot({
1019
- accountId: acc,
1020
- connected: this.isOnline(acc),
1021
- pending: Array.from(this.outbox.values()).filter((v) => v.accountId === acc).length,
1022
- deadLetter: this.deadLetter.filter((v) => v.accountId === acc).length,
1023
- activeConnections: this.activeConnectionCount(acc),
1024
- connectEvents: this.getCounter(this.connectEventsByAccount, acc),
1025
- inboundEvents: this.getCounter(this.inboundEventsByAccount, acc),
1026
- activityEvents: this.getCounter(this.activityEventsByAccount, acc),
1027
- ackEvents: this.getCounter(this.ackEventsByAccount, acc),
1028
- startedAt: this.startedAt,
1029
- lastSession: this.lastSessionByAccount.get(acc) || null,
1030
- lastActivityAt: this.lastActivityByAccount.get(acc) || null,
1031
- lastInboundAt: this.lastInboundByAccount.get(acc) || null,
1032
- lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
1033
- sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc).length,
1034
- invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
1035
- legacyAccountResidue: this.countLegacyAccountResidue(acc),
1036
- running: true,
1037
- channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
1038
- });
1039
- }
1040
-
1041
- private buildStatusHeadline(accountId: string): string {
1042
- const acc = normalizeAccountId(accountId);
1043
- return buildStatusHeadlineFromRuntime({
1044
- accountId: acc,
1045
- connected: this.isOnline(acc),
1046
- pending: Array.from(this.outbox.values()).filter((v) => v.accountId === acc).length,
1047
- deadLetter: this.deadLetter.filter((v) => v.accountId === acc).length,
1048
- activeConnections: this.activeConnectionCount(acc),
1049
- connectEvents: this.getCounter(this.connectEventsByAccount, acc),
1050
- inboundEvents: this.getCounter(this.inboundEventsByAccount, acc),
1051
- activityEvents: this.getCounter(this.activityEventsByAccount, acc),
1052
- ackEvents: this.getCounter(this.ackEventsByAccount, acc),
1053
- startedAt: this.startedAt,
1054
- lastSession: this.lastSessionByAccount.get(acc) || null,
1055
- lastActivityAt: this.lastActivityByAccount.get(acc) || null,
1056
- lastInboundAt: this.lastInboundByAccount.get(acc) || null,
1057
- lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
1058
- sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc).length,
1059
- invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
1060
- legacyAccountResidue: this.countLegacyAccountResidue(acc),
1061
- channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
1062
- });
1063
- }
1064
-
1065
- getStatusHeadline(accountId: string): string {
1066
- return this.buildStatusHeadline(accountId);
1067
- }
1068
-
1069
- getChannelSummary(defaultAccountId: string) {
1070
- const accountId = normalizeAccountId(defaultAccountId);
1071
- const runtime = this.getAccountRuntimeSnapshot(accountId);
1072
- const headline = this.buildStatusHeadline(accountId);
1073
-
1074
- if (runtime.connected) {
1075
- return { linked: true, self: { e164: headline } };
1076
- }
1077
-
1078
- // 顶层汇总不绑定某个 accountId:任一账号在线都应显示 linked
1079
- const t = now();
1080
- for (const c of this.connections.values()) {
1081
- if (t - c.lastSeenAt <= CONNECT_TTL_MS) {
1082
- return { linked: true, self: { e164: headline } };
1083
- }
1084
- }
1085
-
1086
- return { linked: false, self: { e164: headline } };
1087
- }
1088
-
1089
- private enqueueOutbound(entry: OutboxEntry) {
1090
- this.outbox.set(entry.messageId, entry);
1091
- this.scheduleSave();
1092
- this.wakeAccountWaiters(entry.accountId);
1093
- this.flushPushQueue(entry.accountId);
1094
- }
1095
-
1096
- private moveToDeadLetter(entry: OutboxEntry, reason: string) {
1097
- const dead: OutboxEntry = {
1098
- ...entry,
1099
- lastError: reason,
1100
- };
1101
- this.deadLetter.push(dead);
1102
- if (this.deadLetter.length > 1000) this.deadLetter = this.deadLetter.slice(-1000);
1103
- this.outbox.delete(entry.messageId);
1104
- this.scheduleSave();
1105
- }
1106
-
1107
- private collectDue(accountId: string, maxBatch: number): Array<Record<string, unknown>> {
1108
- const due: Array<Record<string, unknown>> = [];
1109
- const t = now();
1110
- const key = normalizeAccountId(accountId);
1111
-
1112
- for (const entry of this.outbox.values()) {
1113
- if (entry.accountId !== key) continue;
1114
- if (entry.nextAttemptAt > t) continue;
1115
-
1116
- const nextAttempt = entry.retryCount + 1;
1117
- if (nextAttempt > MAX_RETRY) {
1118
- this.moveToDeadLetter(entry, 'retry-limit');
1119
- continue;
1120
- }
1121
-
1122
- entry.retryCount = nextAttempt;
1123
- entry.lastAttemptAt = t;
1124
- entry.nextAttemptAt = t + backoffMs(nextAttempt);
1125
- this.outbox.set(entry.messageId, entry);
1126
-
1127
- due.push({
1128
- ...entry.payload,
1129
- _meta: {
1130
- retryCount: entry.retryCount,
1131
- nextAttemptAt: entry.nextAttemptAt,
1132
- },
1133
- });
1134
-
1135
- if (due.length >= maxBatch) break;
1136
- }
1137
-
1138
- if (due.length) this.scheduleSave();
1139
- return due;
1140
- }
1141
-
1142
- private async payloadMediaToBase64(
1143
- mediaUrl: string,
1144
- mediaLocalRoots?: readonly string[],
1145
- ): Promise<{ mediaBase64: string; mimeType?: string; fileName?: string }> {
1146
- const loaded = await this.api.runtime.media.loadWebMedia(mediaUrl, {
1147
- localRoots: mediaLocalRoots,
1148
- maxBytes: 20 * 1024 * 1024,
1149
- });
1150
- return {
1151
- mediaBase64: loaded.buffer.toString('base64'),
1152
- mimeType: loaded.contentType,
1153
- fileName: loaded.fileName,
1154
- };
1155
- }
1156
-
1157
- private async sleepMs(ms: number): Promise<void> {
1158
- await new Promise<void>((resolve) => setTimeout(resolve, Math.max(0, Number(ms || 0))));
1159
- }
1160
-
1161
- private waitChunkAck(params: {
1162
- transferId: string;
1163
- chunkIndex: number;
1164
- timeoutMs?: number;
1165
- }): Promise<void> {
1166
- const { transferId, chunkIndex } = params;
1167
- const timeoutMs = Math.max(1_000, Math.min(Number(params.timeoutMs || FILE_TRANSFER_ACK_TTL_MS), 60_000));
1168
- const started = now();
1169
-
1170
- return new Promise<void>((resolve, reject) => {
1171
- const tick = async () => {
1172
- const st = this.fileSendTransfers.get(transferId);
1173
- if (!st) {
1174
- reject(new Error('transfer state missing'));
1175
- return;
1176
- }
1177
- if (st.failedChunks.has(chunkIndex)) {
1178
- reject(new Error(st.failedChunks.get(chunkIndex) || `chunk ${chunkIndex} failed`));
1179
- return;
1180
- }
1181
- if (st.ackedChunks.has(chunkIndex)) {
1182
- resolve();
1183
- return;
1184
- }
1185
- if (now() - started >= timeoutMs) {
1186
- reject(new Error(`chunk ack timeout index=${chunkIndex}`));
1187
- return;
1188
- }
1189
- await this.sleepMs(120);
1190
- void tick();
1191
- };
1192
- void tick();
1193
- });
1194
- }
1195
-
1196
- private waitCompleteAck(params: {
1197
- transferId: string;
1198
- timeoutMs?: number;
1199
- }): Promise<{ path: string }> {
1200
- const { transferId } = params;
1201
- const timeoutMs = Math.max(2_000, Math.min(Number(params.timeoutMs || 60_000), 120_000));
1202
- const started = now();
1203
-
1204
- return new Promise<{ path: string }>((resolve, reject) => {
1205
- const tick = async () => {
1206
- const st = this.fileSendTransfers.get(transferId);
1207
- if (!st) {
1208
- reject(new Error('transfer state missing'));
1209
- return;
1210
- }
1211
- if (st.status === 'aborted') {
1212
- reject(new Error(st.error || 'transfer aborted'));
1213
- return;
1214
- }
1215
- if (st.status === 'completed' && st.completedPath) {
1216
- resolve({ path: st.completedPath });
1217
- return;
1218
- }
1219
- if (now() - started >= timeoutMs) {
1220
- reject(new Error('complete ack timeout'));
1221
- return;
1222
- }
1223
- await this.sleepMs(150);
1224
- void tick();
1225
- };
1226
- void tick();
1227
- });
1228
- }
1229
-
1230
- private async transferMediaToBncrClient(params: {
1231
- accountId: string;
1232
- sessionKey: string;
1233
- route: BncrRoute;
1234
- mediaUrl: string;
1235
- mediaLocalRoots?: readonly string[];
1236
- }): Promise<{ mode: 'base64' | 'chunk'; mimeType?: string; fileName?: string; mediaBase64?: string; path?: string }> {
1237
- const loaded = await this.api.runtime.media.loadWebMedia(params.mediaUrl, {
1238
- localRoots: params.mediaLocalRoots,
1239
- maxBytes: 50 * 1024 * 1024,
1240
- });
1241
-
1242
- const size = loaded.buffer.byteLength;
1243
- const mimeType = loaded.contentType;
1244
- const fileName = resolveOutboundFileName({
1245
- mediaUrl: params.mediaUrl,
1246
- fileName: loaded.fileName,
1247
- mimeType,
1248
- });
1249
-
1250
- if (!FILE_FORCE_CHUNK && size <= FILE_INLINE_THRESHOLD) {
1251
- return {
1252
- mode: 'base64',
1253
- mimeType,
1254
- fileName,
1255
- mediaBase64: loaded.buffer.toString('base64'),
1256
- };
1257
- }
1258
-
1259
- const ctx = this.gatewayContext;
1260
- if (!ctx) throw new Error('gateway context unavailable');
1261
-
1262
- const connIds = this.resolvePushConnIds(params.accountId);
1263
- if (!connIds.size) throw new Error('no active bncr client for file chunk transfer');
1264
-
1265
- const transferId = randomUUID();
1266
- const chunkSize = 256 * 1024;
1267
- const totalChunks = Math.ceil(size / chunkSize);
1268
- const fileSha256 = createHash('sha256').update(loaded.buffer).digest('hex');
1269
-
1270
- const st: FileSendTransferState = {
1271
- transferId,
1272
- accountId: normalizeAccountId(params.accountId),
1273
- sessionKey: params.sessionKey,
1274
- route: params.route,
1275
- fileName,
1276
- mimeType: mimeType || 'application/octet-stream',
1277
- fileSize: size,
1278
- chunkSize,
1279
- totalChunks,
1280
- fileSha256,
1281
- startedAt: now(),
1282
- status: 'init',
1283
- ackedChunks: new Set(),
1284
- failedChunks: new Map(),
1285
- };
1286
- this.fileSendTransfers.set(transferId, st);
1287
-
1288
- ctx.broadcastToConnIds('bncr.file.init', {
1289
- transferId,
1290
- direction: 'oc2bncr',
1291
- sessionKey: params.sessionKey,
1292
- platform: params.route.platform,
1293
- groupId: params.route.groupId,
1294
- userId: params.route.userId,
1295
- fileName,
1296
- mimeType,
1297
- fileSize: size,
1298
- chunkSize,
1299
- totalChunks,
1300
- fileSha256,
1301
- ts: now(),
1302
- }, connIds);
1303
-
1304
- // 逐块发送并等待 ACK
1305
- for (let idx = 0; idx < totalChunks; idx++) {
1306
- const start = idx * chunkSize;
1307
- const end = Math.min(start + chunkSize, size);
1308
- const slice = loaded.buffer.subarray(start, end);
1309
- const chunkSha256 = createHash('sha256').update(slice).digest('hex');
1310
-
1311
- let ok = false;
1312
- let lastErr: unknown = null;
1313
- for (let attempt = 1; attempt <= 3; attempt++) {
1314
- ctx.broadcastToConnIds('bncr.file.chunk', {
1315
- transferId,
1316
- chunkIndex: idx,
1317
- offset: start,
1318
- size: slice.byteLength,
1319
- chunkSha256,
1320
- base64: slice.toString('base64'),
1321
- ts: now(),
1322
- }, connIds);
1323
-
1324
- try {
1325
- await this.waitChunkAck({ transferId, chunkIndex: idx, timeoutMs: FILE_TRANSFER_ACK_TTL_MS });
1326
- ok = true;
1327
- break;
1328
- } catch (err) {
1329
- lastErr = err;
1330
- await this.sleepMs(150 * attempt);
1331
- }
1332
- }
1333
-
1334
- if (!ok) {
1335
- st.status = 'aborted';
1336
- st.error = String((lastErr as any)?.message || lastErr || `chunk-${idx}-failed`);
1337
- this.fileSendTransfers.set(transferId, st);
1338
- ctx.broadcastToConnIds('bncr.file.abort', {
1339
- transferId,
1340
- reason: st.error,
1341
- ts: now(),
1342
- }, connIds);
1343
- throw new Error(st.error);
1344
- }
1345
- }
1346
-
1347
- ctx.broadcastToConnIds('bncr.file.complete', {
1348
- transferId,
1349
- ts: now(),
1350
- }, connIds);
1351
-
1352
- const done = await this.waitCompleteAck({ transferId, timeoutMs: 60_000 });
1353
-
1354
- return {
1355
- mode: 'chunk',
1356
- mimeType,
1357
- fileName,
1358
- path: done.path,
1359
- };
1360
- }
1361
-
1362
- private async enqueueFromReply(params: {
1363
- accountId: string;
1364
- sessionKey: string;
1365
- route: BncrRoute;
1366
- payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] };
1367
- mediaLocalRoots?: readonly string[];
1368
- }) {
1369
- const { accountId, sessionKey, route, payload, mediaLocalRoots } = params;
1370
-
1371
- const mediaList = payload.mediaUrls?.length
1372
- ? payload.mediaUrls
1373
- : payload.mediaUrl
1374
- ? [payload.mediaUrl]
1375
- : [];
1376
-
1377
- if (mediaList.length > 0) {
1378
- let first = true;
1379
- for (const mediaUrl of mediaList) {
1380
- const media = await this.transferMediaToBncrClient({
1381
- accountId,
1382
- sessionKey,
1383
- route,
1384
- mediaUrl,
1385
- mediaLocalRoots,
1386
- });
1387
- const messageId = randomUUID();
1388
- const mediaMsg = first ? asString(payload.text || '') : '';
1389
- const frame = buildBncrMediaOutboundFrame({
1390
- messageId,
1391
- sessionKey,
1392
- route,
1393
- media,
1394
- mediaUrl,
1395
- mediaMsg,
1396
- fileName: resolveOutboundFileName({
1397
- mediaUrl,
1398
- fileName: media.fileName,
1399
- mimeType: media.mimeType,
1400
- }),
1401
- now: now(),
1402
- });
1403
-
1404
- this.enqueueOutbound({
1405
- messageId,
1406
- accountId: normalizeAccountId(accountId),
1407
- sessionKey,
1408
- route,
1409
- payload: frame,
1410
- createdAt: now(),
1411
- retryCount: 0,
1412
- nextAttemptAt: now(),
1413
- });
1414
- first = false;
1415
- }
1416
- return;
1417
- }
1418
-
1419
- const text = asString(payload.text || '').trim();
1420
- if (!text) return;
1421
-
1422
- const messageId = randomUUID();
1423
- const frame = {
1424
- type: 'message.outbound',
1425
- messageId,
1426
- idempotencyKey: messageId,
1427
- sessionKey,
1428
- message: {
1429
- platform: route.platform,
1430
- groupId: route.groupId,
1431
- userId: route.userId,
1432
- type: 'text',
1433
- msg: text,
1434
- path: '',
1435
- base64: '',
1436
- fileName: '',
1437
- },
1438
- ts: now(),
1439
- };
1440
-
1441
- this.enqueueOutbound({
1442
- messageId,
1443
- accountId: normalizeAccountId(accountId),
1444
- sessionKey,
1445
- route,
1446
- payload: frame,
1447
- createdAt: now(),
1448
- retryCount: 0,
1449
- nextAttemptAt: now(),
1450
- });
1451
- }
1452
-
1453
- handleConnect = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
1454
- const accountId = normalizeAccountId(asString(params?.accountId || ''));
1455
- const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
1456
- const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1457
-
1458
- this.rememberGatewayContext(context);
1459
- this.markSeen(accountId, connId, clientId);
1460
- this.markActivity(accountId);
1461
- this.incrementCounter(this.connectEventsByAccount, accountId);
1462
-
1463
- respond(true, {
1464
- channel: CHANNEL_ID,
1465
- accountId,
1466
- bridgeVersion: BRIDGE_VERSION,
1467
- pushEvent: BNCR_PUSH_EVENT,
1468
- online: true,
1469
- isPrimary: this.isPrimaryConnection(accountId, clientId),
1470
- activeConnections: this.activeConnectionCount(accountId),
1471
- pending: Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length,
1472
- deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
1473
- diagnostics: this.buildIntegratedDiagnostics(accountId),
1474
- now: now(),
1475
- });
1476
-
1477
- // WS 一旦在线,立即尝试把离线期间积压队列直推出去
1478
- this.flushPushQueue(accountId);
1479
- };
1480
-
1481
- handleAck = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
1482
- const accountId = normalizeAccountId(asString(params?.accountId || ''));
1483
- const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
1484
- const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1485
- this.rememberGatewayContext(context);
1486
- this.markSeen(accountId, connId, clientId);
1487
- this.incrementCounter(this.ackEventsByAccount, accountId);
1488
-
1489
- const messageId = asString(params?.messageId || '').trim();
1490
- if (!messageId) {
1491
- respond(false, { error: 'messageId required' });
1492
- return;
1493
- }
1494
-
1495
- const entry = this.outbox.get(messageId);
1496
- if (!entry) {
1497
- respond(true, { ok: true, message: 'already-acked-or-missing' });
1498
- return;
1499
- }
1500
-
1501
- if (entry.accountId !== accountId) {
1502
- respond(false, { error: 'account mismatch' });
1503
- return;
1504
- }
1505
-
1506
- const ok = params?.ok !== false;
1507
- const fatal = params?.fatal === true;
1508
-
1509
- if (ok) {
1510
- this.outbox.delete(messageId);
1511
- this.scheduleSave();
1512
- respond(true, { ok: true });
1513
- return;
1514
- }
1515
-
1516
- if (fatal) {
1517
- this.moveToDeadLetter(entry, asString(params?.error || 'fatal-ack'));
1518
- respond(true, { ok: true, movedToDeadLetter: true });
1519
- return;
1520
- }
1521
-
1522
- entry.nextAttemptAt = now() + 1_000;
1523
- entry.lastError = asString(params?.error || 'retryable-ack');
1524
- this.outbox.set(messageId, entry);
1525
- this.scheduleSave();
1526
-
1527
- respond(true, { ok: true, willRetry: true });
1528
- };
1529
-
1530
- handleActivity = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
1531
- const accountId = normalizeAccountId(asString(params?.accountId || ''));
1532
- const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
1533
- const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1534
- this.rememberGatewayContext(context);
1535
- this.markSeen(accountId, connId, clientId);
1536
- this.markActivity(accountId);
1537
- this.incrementCounter(this.activityEventsByAccount, accountId);
1538
-
1539
- // 轻量活动心跳:仅刷新在线活跃状态,不承担拉取职责。
1540
- respond(true, {
1541
- accountId,
1542
- ok: true,
1543
- event: 'activity',
1544
- activeConnections: this.activeConnectionCount(accountId),
1545
- pending: Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length,
1546
- deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
1547
- now: now(),
1548
- });
1549
- };
1550
-
1551
- handleDiagnostics = async ({ params, respond }: GatewayRequestHandlerOptions) => {
1552
- const accountId = normalizeAccountId(asString(params?.accountId || ''));
1553
- const cfg = await this.api.runtime.config.loadConfig();
1554
- const runtime = this.getAccountRuntimeSnapshot(accountId);
1555
- const diagnostics = this.buildIntegratedDiagnostics(accountId);
1556
- const permissions = buildBncrPermissionSummary(cfg ?? {});
1557
- const probe = probeBncrAccount({
1558
- accountId,
1559
- connected: Boolean(runtime?.connected),
1560
- pending: Number(runtime?.meta?.pending ?? 0),
1561
- deadLetter: Number(runtime?.meta?.deadLetter ?? 0),
1562
- activeConnections: this.activeConnectionCount(accountId),
1563
- invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(accountId),
1564
- legacyAccountResidue: this.countLegacyAccountResidue(accountId),
1565
- lastActivityAt: runtime?.meta?.lastActivityAt ?? null,
1566
- structure: {
1567
- coreComplete: true,
1568
- inboundComplete: true,
1569
- outboundComplete: true,
1570
- },
1571
- });
1572
-
1573
- respond(true, {
1574
- channel: CHANNEL_ID,
1575
- accountId,
1576
- runtime,
1577
- diagnostics,
1578
- permissions,
1579
- probe,
1580
- now: now(),
1581
- });
1582
- };
1583
-
1584
- handleFileInit = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
1585
- const accountId = normalizeAccountId(asString(params?.accountId || ''));
1586
- const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
1587
- const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1588
- this.rememberGatewayContext(context);
1589
- this.markSeen(accountId, connId, clientId);
1590
- this.markActivity(accountId);
1591
-
1592
- const transferId = asString(params?.transferId || '').trim();
1593
- const sessionKey = asString(params?.sessionKey || '').trim();
1594
- const fileName = asString(params?.fileName || '').trim() || 'file.bin';
1595
- const mimeType = asString(params?.mimeType || '').trim() || 'application/octet-stream';
1596
- const fileSize = Number(params?.fileSize || 0);
1597
- const chunkSize = Number(params?.chunkSize || 256 * 1024);
1598
- const totalChunks = Number(params?.totalChunks || 0);
1599
- const fileSha256 = asString(params?.fileSha256 || '').trim();
1600
-
1601
- if (!transferId || !sessionKey || !fileSize || !chunkSize || !totalChunks) {
1602
- respond(false, { error: 'transferId/sessionKey/fileSize/chunkSize/totalChunks required' });
1603
- return;
1604
- }
1605
-
1606
- const normalized = normalizeStoredSessionKey(sessionKey);
1607
- if (!normalized) {
1608
- respond(false, { error: 'invalid sessionKey' });
1609
- return;
1610
- }
1611
-
1612
- const existing = this.fileRecvTransfers.get(transferId);
1613
- if (existing) {
1614
- respond(true, {
1615
- ok: true,
1616
- transferId,
1617
- status: existing.status,
1618
- duplicated: true,
1619
- });
1620
- return;
1621
- }
1622
-
1623
- const route = parseRouteLike({
1624
- platform: asString(params?.platform || normalized.route.platform),
1625
- groupId: asString(params?.groupId || normalized.route.groupId),
1626
- userId: asString(params?.userId || normalized.route.userId),
1627
- }) || normalized.route;
1628
-
1629
- this.fileRecvTransfers.set(transferId, {
1630
- transferId,
1631
- accountId,
1632
- sessionKey: normalized.sessionKey,
1633
- route,
1634
- fileName,
1635
- mimeType,
1636
- fileSize,
1637
- chunkSize,
1638
- totalChunks,
1639
- fileSha256,
1640
- startedAt: now(),
1641
- status: 'init',
1642
- bufferByChunk: new Map(),
1643
- receivedChunks: new Set(),
1644
- });
1645
-
1646
- respond(true, {
1647
- ok: true,
1648
- transferId,
1649
- status: 'init',
1650
- });
1651
- };
1652
-
1653
- handleFileChunk = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
1654
- const accountId = normalizeAccountId(asString(params?.accountId || ''));
1655
- const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
1656
- const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1657
- this.rememberGatewayContext(context);
1658
- this.markSeen(accountId, connId, clientId);
1659
- this.markActivity(accountId);
1660
-
1661
- const transferId = asString(params?.transferId || '').trim();
1662
- const chunkIndex = Number(params?.chunkIndex ?? -1);
1663
- const offset = Number(params?.offset ?? 0);
1664
- const size = Number(params?.size ?? 0);
1665
- const chunkSha256 = asString(params?.chunkSha256 || '').trim();
1666
- const base64 = asString(params?.base64 || '');
1667
-
1668
- if (!transferId || chunkIndex < 0 || !base64) {
1669
- respond(false, { error: 'transferId/chunkIndex/base64 required' });
1670
- return;
1671
- }
1672
-
1673
- const st = this.fileRecvTransfers.get(transferId);
1674
- if (!st) {
1675
- respond(false, { error: 'transfer not found' });
1676
- return;
1677
- }
1678
-
1679
- try {
1680
- const buf = Buffer.from(base64, 'base64');
1681
- if (size > 0 && buf.length !== size) {
1682
- throw new Error(`chunk size mismatch expected=${size} got=${buf.length}`);
1683
- }
1684
- if (chunkSha256) {
1685
- const digest = createHash('sha256').update(buf).digest('hex');
1686
- if (digest !== chunkSha256) throw new Error('chunk sha256 mismatch');
1687
- }
1688
- st.bufferByChunk.set(chunkIndex, buf);
1689
- st.receivedChunks.add(chunkIndex);
1690
- st.status = 'transferring';
1691
- this.fileRecvTransfers.set(transferId, st);
1692
-
1693
- respond(true, {
1694
- ok: true,
1695
- transferId,
1696
- chunkIndex,
1697
- offset,
1698
- received: st.receivedChunks.size,
1699
- totalChunks: st.totalChunks,
1700
- });
1701
- } catch (error) {
1702
- respond(false, { error: String((error as any)?.message || error || 'chunk invalid') });
1703
- }
1704
- };
1705
-
1706
- handleFileComplete = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
1707
- const accountId = normalizeAccountId(asString(params?.accountId || ''));
1708
- const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
1709
- const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1710
- this.rememberGatewayContext(context);
1711
- this.markSeen(accountId, connId, clientId);
1712
- this.markActivity(accountId);
1713
-
1714
- const transferId = asString(params?.transferId || '').trim();
1715
- if (!transferId) {
1716
- respond(false, { error: 'transferId required' });
1717
- return;
1718
- }
1719
-
1720
- const st = this.fileRecvTransfers.get(transferId);
1721
- if (!st) {
1722
- respond(false, { error: 'transfer not found' });
1723
- return;
1724
- }
1725
-
1726
- try {
1727
- if (st.receivedChunks.size < st.totalChunks) {
1728
- throw new Error(`chunk not complete received=${st.receivedChunks.size} total=${st.totalChunks}`);
1729
- }
1730
-
1731
- const ordered = Array.from(st.bufferByChunk.entries()).sort((a, b) => a[0] - b[0]).map((x) => x[1]);
1732
- const merged = Buffer.concat(ordered);
1733
- if (st.fileSize > 0 && merged.length !== st.fileSize) {
1734
- throw new Error(`file size mismatch expected=${st.fileSize} got=${merged.length}`);
1735
- }
1736
- const digest = createHash('sha256').update(merged).digest('hex');
1737
- if (st.fileSha256 && digest !== st.fileSha256) {
1738
- throw new Error('file sha256 mismatch');
1739
- }
1740
-
1741
- const saved = await this.api.runtime.channel.media.saveMediaBuffer(
1742
- merged,
1743
- st.mimeType,
1744
- 'inbound',
1745
- 50 * 1024 * 1024,
1746
- st.fileName,
1747
- );
1748
- st.completedPath = saved.path;
1749
- st.status = 'completed';
1750
- this.fileRecvTransfers.set(transferId, st);
1751
-
1752
- respond(true, {
1753
- ok: true,
1754
- transferId,
1755
- path: saved.path,
1756
- size: merged.length,
1757
- fileName: st.fileName,
1758
- mimeType: st.mimeType,
1759
- fileSha256: digest,
1760
- });
1761
- } catch (error) {
1762
- st.status = 'aborted';
1763
- st.error = String((error as any)?.message || error || 'complete failed');
1764
- this.fileRecvTransfers.set(transferId, st);
1765
- respond(false, { error: st.error });
1766
- }
1767
- };
1768
-
1769
- handleFileAbort = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
1770
- const accountId = normalizeAccountId(asString(params?.accountId || ''));
1771
- const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
1772
- const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1773
- this.rememberGatewayContext(context);
1774
- this.markSeen(accountId, connId, clientId);
1775
- this.markActivity(accountId);
1776
-
1777
- const transferId = asString(params?.transferId || '').trim();
1778
- if (!transferId) {
1779
- respond(false, { error: 'transferId required' });
1780
- return;
1781
- }
1782
-
1783
- const st = this.fileRecvTransfers.get(transferId);
1784
- if (!st) {
1785
- respond(true, { ok: true, transferId, message: 'not-found' });
1786
- return;
1787
- }
1788
-
1789
- st.status = 'aborted';
1790
- st.error = asString(params?.reason || 'aborted');
1791
- this.fileRecvTransfers.set(transferId, st);
1792
-
1793
- respond(true, {
1794
- ok: true,
1795
- transferId,
1796
- status: 'aborted',
1797
- });
1798
- };
1799
-
1800
- handleFileAck = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
1801
- const accountId = normalizeAccountId(asString(params?.accountId || ''));
1802
- const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
1803
- const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1804
- this.rememberGatewayContext(context);
1805
- this.markSeen(accountId, connId, clientId);
1806
- this.markActivity(accountId);
1807
-
1808
- const transferId = asString(params?.transferId || '').trim();
1809
- const stage = asString(params?.stage || '').trim();
1810
- const ok = params?.ok !== false;
1811
- const chunkIndex = Number(params?.chunkIndex ?? -1);
1812
-
1813
- if (!transferId || !stage) {
1814
- respond(false, { error: 'transferId/stage required' });
1815
- return;
1816
- }
1817
-
1818
- const st = this.fileSendTransfers.get(transferId);
1819
- if (st) {
1820
- if (!ok) {
1821
- const code = asString(params?.errorCode || 'ACK_FAILED');
1822
- const msg = asString(params?.errorMessage || 'ack failed');
1823
- st.error = `${code}:${msg}`;
1824
- if (stage === 'chunk' && chunkIndex >= 0) st.failedChunks.set(chunkIndex, st.error);
1825
- if (stage === 'complete') st.status = 'aborted';
1826
- } else {
1827
- if (stage === 'chunk' && chunkIndex >= 0) {
1828
- st.ackedChunks.add(chunkIndex);
1829
- st.status = 'transferring';
1830
- }
1831
- if (stage === 'complete') {
1832
- st.status = 'completed';
1833
- st.completedPath = asString(params?.path || '').trim() || st.completedPath;
1834
- }
1835
- }
1836
- this.fileSendTransfers.set(transferId, st);
1837
- }
1838
-
1839
- // 唤醒等待中的 chunk/complete ACK
1840
- this.resolveFileAck({
1841
- transferId,
1842
- stage,
1843
- chunkIndex: chunkIndex >= 0 ? chunkIndex : undefined,
1844
- payload: {
1845
- ok,
1846
- transferId,
1847
- stage,
1848
- path: asString(params?.path || '').trim(),
1849
- errorCode: asString(params?.errorCode || ''),
1850
- errorMessage: asString(params?.errorMessage || ''),
1851
- },
1852
- ok,
1853
- });
1854
-
1855
- respond(true, {
1856
- ok: true,
1857
- transferId,
1858
- stage,
1859
- state: st?.status || 'late',
1860
- });
1861
- };
1862
-
1863
- handleInbound = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
1864
- const parsed = parseBncrInboundParams(params);
1865
- const { accountId, platform, groupId, userId, sessionKeyfromroute, route, text, msgType, mediaBase64, mediaPathFromTransfer, mimeType, fileName, msgId, dedupKey, peer, extracted } = parsed;
1866
- const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
1867
- const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1868
- this.rememberGatewayContext(context);
1869
- this.markSeen(accountId, connId, clientId);
1870
- this.markActivity(accountId);
1871
- this.incrementCounter(this.inboundEventsByAccount, accountId);
1872
-
1873
- if (!platform || (!userId && !groupId)) {
1874
- respond(false, { error: 'platform/groupId/userId required' });
1875
- return;
1876
- }
1877
- if (this.markInboundDedupSeen(dedupKey)) {
1878
- respond(true, {
1879
- accepted: true,
1880
- duplicated: true,
1881
- accountId,
1882
- msgId: msgId ?? null,
1883
- });
1884
- return;
1885
- }
1886
-
1887
- const cfg = await this.api.runtime.config.loadConfig();
1888
- const gate = checkBncrMessageGate({
1889
- parsed,
1890
- cfg,
1891
- account: resolveAccount(cfg, accountId),
1892
- });
1893
- if (!gate.allowed) {
1894
- respond(true, {
1895
- accepted: false,
1896
- accountId,
1897
- msgId: msgId ?? null,
1898
- reason: gate.reason,
1899
- });
1900
- return;
1901
- }
1902
-
1903
- const baseSessionKey = normalizeInboundSessionKey(sessionKeyfromroute, route)
1904
- || this.api.runtime.channel.routing.resolveAgentRoute({
1905
- cfg,
1906
- channel: CHANNEL_ID,
1907
- accountId,
1908
- peer,
1909
- }).sessionKey;
1910
- const taskSessionKey = withTaskSessionKey(baseSessionKey, extracted.taskKey);
1911
- const sessionKey = taskSessionKey || baseSessionKey;
1912
-
1913
- respond(true, {
1914
- accepted: true,
1915
- accountId,
1916
- sessionKey,
1917
- msgId: msgId ?? null,
1918
- taskKey: extracted.taskKey ?? null,
1919
- });
1920
-
1921
- void dispatchBncrInbound({
1922
- api: this.api,
1923
- channelId: CHANNEL_ID,
1924
- cfg,
1925
- parsed,
1926
- rememberSessionRoute: (sessionKey, accountId, route) => this.rememberSessionRoute(sessionKey, accountId, route),
1927
- enqueueFromReply: (args) => this.enqueueFromReply(args),
1928
- setInboundActivity: (accountId, at) => {
1929
- this.lastInboundByAccount.set(accountId, at);
1930
- this.markActivity(accountId, at);
1931
- },
1932
- scheduleSave: () => this.scheduleSave(),
1933
- logger: this.api.logger,
1934
- }).catch((err) => {
1935
- this.api.logger.error?.(`bncr inbound process failed: ${String(err)}`);
1936
- });
1937
- };
1938
-
1939
- channelStartAccount = async (ctx: any) => {
1940
- const accountId = normalizeAccountId(ctx.accountId);
1941
-
1942
- const tick = () => {
1943
- const connected = this.isOnline(accountId);
1944
- const previous = ctx.getStatus?.() || {};
1945
- const lastActAt = this.lastActivityByAccount.get(accountId) || previous?.lastEventAt || null;
1946
-
1947
- ctx.setStatus?.({
1948
- ...previous,
1949
- accountId,
1950
- running: true,
1951
- connected,
1952
- lastEventAt: lastActAt,
1953
- // 状态映射:在线=linked,离线=configured
1954
- mode: connected ? 'linked' : 'configured',
1955
- lastError: previous?.lastError ?? null,
1956
- meta: this.buildStatusMeta(accountId),
1957
- });
1958
- };
1959
-
1960
- tick();
1961
- const timer = setInterval(tick, 5_000);
1962
-
1963
- await new Promise<void>((resolve) => {
1964
- const onAbort = () => {
1965
- clearInterval(timer);
1966
- resolve();
1967
- };
1968
-
1969
- if (ctx.abortSignal?.aborted) {
1970
- onAbort();
1971
- return;
1972
- }
1973
-
1974
- ctx.abortSignal?.addEventListener?.('abort', onAbort, { once: true });
1975
- });
1976
- };
1977
-
1978
- channelStopAccount = async (_ctx: any) => {
1979
- // no-op
1980
- };
1981
-
1982
- channelSendText = async (ctx: any) => {
1983
- const accountId = normalizeAccountId(ctx.accountId);
1984
- const to = asString(ctx.to || '').trim();
1985
-
1986
- if (BNCR_DEBUG_VERBOSE) {
1987
- this.api.logger.info?.(
1988
- `[bncr-send-entry:text] ${JSON.stringify({
1989
- accountId,
1990
- to,
1991
- text: asString(ctx?.text || ''),
1992
- mediaUrl: asString(ctx?.mediaUrl || ''),
1993
- sessionKey: asString(ctx?.sessionKey || ''),
1994
- mirrorSessionKey: asString(ctx?.mirror?.sessionKey || ''),
1995
- rawCtx: {
1996
- to: ctx?.to,
1997
- accountId: ctx?.accountId,
1998
- threadId: ctx?.threadId,
1999
- replyToId: ctx?.replyToId,
2000
- },
2001
- })}`,
2002
- );
2003
- }
2004
-
2005
- return sendBncrText({
2006
- channelId: CHANNEL_ID,
2007
- accountId,
2008
- to,
2009
- text: asString(ctx.text || ''),
2010
- mediaLocalRoots: ctx.mediaLocalRoots,
2011
- resolveVerifiedTarget: (to, accountId) => this.resolveVerifiedTarget(to, accountId),
2012
- rememberSessionRoute: (sessionKey, accountId, route) => this.rememberSessionRoute(sessionKey, accountId, route),
2013
- enqueueFromReply: (args) => this.enqueueFromReply(args),
2014
- createMessageId: () => randomUUID(),
2015
- });
2016
- };
2017
-
2018
- channelSendMedia = async (ctx: any) => {
2019
- const accountId = normalizeAccountId(ctx.accountId);
2020
- const to = asString(ctx.to || '').trim();
2021
-
2022
- if (BNCR_DEBUG_VERBOSE) {
2023
- this.api.logger.info?.(
2024
- `[bncr-send-entry:media] ${JSON.stringify({
2025
- accountId,
2026
- to,
2027
- text: asString(ctx?.text || ''),
2028
- mediaUrl: asString(ctx?.mediaUrl || ''),
2029
- mediaUrls: Array.isArray(ctx?.mediaUrls) ? ctx.mediaUrls : undefined,
2030
- sessionKey: asString(ctx?.sessionKey || ''),
2031
- mirrorSessionKey: asString(ctx?.mirror?.sessionKey || ''),
2032
- rawCtx: {
2033
- to: ctx?.to,
2034
- accountId: ctx?.accountId,
2035
- threadId: ctx?.threadId,
2036
- replyToId: ctx?.replyToId,
2037
- },
2038
- })}`,
2039
- );
2040
- }
2041
-
2042
- return sendBncrMedia({
2043
- channelId: CHANNEL_ID,
2044
- accountId,
2045
- to,
2046
- text: asString(ctx.text || ''),
2047
- mediaUrl: asString(ctx.mediaUrl || ''),
2048
- mediaLocalRoots: ctx.mediaLocalRoots,
2049
- resolveVerifiedTarget: (to, accountId) => this.resolveVerifiedTarget(to, accountId),
2050
- rememberSessionRoute: (sessionKey, accountId, route) => this.rememberSessionRoute(sessionKey, accountId, route),
2051
- enqueueFromReply: (args) => this.enqueueFromReply(args),
2052
- createMessageId: () => randomUUID(),
2053
- });
2054
- };
2055
- }
2056
-
2057
- export function createBncrBridge(api: OpenClawPluginApi) {
2058
- return new BncrBridgeRuntime(api);
2059
- }
2060
-
2061
- export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2062
- const plugin = {
2063
- id: CHANNEL_ID,
2064
- meta: {
2065
- id: CHANNEL_ID,
2066
- label: 'Bncr',
2067
- selectionLabel: 'Bncr Client',
2068
- docsPath: '/channels/bncr',
2069
- blurb: 'Bncr Channel.',
2070
- aliases: ['bncr'],
2071
- },
2072
- capabilities: {
2073
- chatTypes: ['direct'] as ChatType[],
2074
- media: true,
2075
- reply: true,
2076
- nativeCommands: true,
2077
- },
2078
- messaging: {
2079
- // 接收任意标签输入;不在 normalize 阶段做格式门槛,统一下沉到发送前验证。
2080
- normalizeTarget: (raw: string) => {
2081
- const input = asString(raw).trim();
2082
- return input || undefined;
2083
- },
2084
- targetResolver: {
2085
- looksLikeId: (raw: string, normalized?: string) => {
2086
- return Boolean(asString(normalized || raw).trim());
2087
- },
2088
- hint: 'Standard to=Bncr:<platform>:<group>:<user> or Bncr:<platform>:<user>; sessionKey keeps existing strict/legacy compatibility, canonical sessionKey=agent:main:bncr:direct:<hex>',
2089
- },
2090
- },
2091
- configSchema: BncrConfigSchema,
2092
- config: {
2093
- listAccountIds,
2094
- resolveAccount,
2095
- setAccountEnabled: ({ cfg, accountId, enabled }: any) =>
2096
- setAccountEnabledInConfigSection({
2097
- cfg,
2098
- sectionKey: CHANNEL_ID,
2099
- accountId,
2100
- enabled,
2101
- allowTopLevel: true,
2102
- }),
2103
- isEnabled: (account: any, cfg: any) => {
2104
- const policy = resolveBncrChannelPolicy(cfg?.channels?.[CHANNEL_ID] || {});
2105
- return policy.enabled !== false && account?.enabled !== false;
2106
- },
2107
- isConfigured: () => true,
2108
- describeAccount: (account: any) => {
2109
- const displayName = resolveDefaultDisplayName(account?.name, account?.accountId);
2110
- return {
2111
- accountId: account.accountId,
2112
- name: displayName,
2113
- enabled: account.enabled !== false,
2114
- configured: true,
2115
- };
2116
- },
2117
- },
2118
- setup: {
2119
- applyAccountName: ({ cfg, accountId, name }: any) =>
2120
- applyAccountNameToChannelSection({
2121
- cfg,
2122
- channelKey: CHANNEL_ID,
2123
- accountId,
2124
- name,
2125
- alwaysUseAccounts: true,
2126
- }),
2127
- applyAccountConfig: ({ cfg, accountId }: any) => {
2128
- const next = { ...(cfg || {}) } as any;
2129
- next.channels = next.channels || {};
2130
- next.channels[CHANNEL_ID] = next.channels[CHANNEL_ID] || {};
2131
- next.channels[CHANNEL_ID].accounts = next.channels[CHANNEL_ID].accounts || {};
2132
- next.channels[CHANNEL_ID].accounts[accountId] = {
2133
- ...(next.channels[CHANNEL_ID].accounts[accountId] || {}),
2134
- enabled: true,
2135
- };
2136
- return next;
2137
- },
2138
- },
2139
- outbound: {
2140
- deliveryMode: 'gateway' as const,
2141
- textChunkLimit: 4000,
2142
- sendText: bridge.channelSendText,
2143
- sendMedia: bridge.channelSendMedia,
2144
- replyAction: async (ctx: any) => sendBncrReplyAction({
2145
- accountId: normalizeAccountId(ctx?.accountId),
2146
- to: asString(ctx?.to || '').trim(),
2147
- text: asString(ctx?.text || ''),
2148
- replyToMessageId: asString(ctx?.replyToId || ctx?.replyToMessageId || '').trim() || undefined,
2149
- sendText: async ({ accountId, to, text }) => bridge.channelSendText({ accountId, to, text }),
2150
- }),
2151
- deleteAction: async (ctx: any) => deleteBncrMessageAction({
2152
- accountId: normalizeAccountId(ctx?.accountId),
2153
- targetMessageId: asString(ctx?.messageId || ctx?.targetMessageId || '').trim(),
2154
- }),
2155
- reactAction: async (ctx: any) => reactBncrMessageAction({
2156
- accountId: normalizeAccountId(ctx?.accountId),
2157
- targetMessageId: asString(ctx?.messageId || ctx?.targetMessageId || '').trim(),
2158
- emoji: asString(ctx?.emoji || '').trim(),
2159
- }),
2160
- editAction: async (ctx: any) => editBncrMessageAction({
2161
- accountId: normalizeAccountId(ctx?.accountId),
2162
- targetMessageId: asString(ctx?.messageId || ctx?.targetMessageId || '').trim(),
2163
- text: asString(ctx?.text || ''),
2164
- }),
2165
- },
2166
- status: {
2167
- defaultRuntime: createDefaultChannelRuntimeState(BNCR_DEFAULT_ACCOUNT_ID, {
2168
- mode: 'ws-offline',
2169
- }),
2170
- buildChannelSummary: async ({ defaultAccountId }: any) => {
2171
- return bridge.getChannelSummary(defaultAccountId || BNCR_DEFAULT_ACCOUNT_ID);
2172
- },
2173
- buildAccountSnapshot: async ({ account, runtime }: any) => {
2174
- const rt = runtime || bridge.getAccountRuntimeSnapshot(account?.accountId);
2175
- const meta = rt?.meta || {};
2176
-
2177
- const pending = Number(rt?.pending ?? meta.pending ?? 0);
2178
- const deadLetter = Number(rt?.deadLetter ?? meta.deadLetter ?? 0);
2179
- const lastSessionKey = rt?.lastSessionKey ?? meta.lastSessionKey ?? null;
2180
- const lastSessionScope = rt?.lastSessionScope ?? meta.lastSessionScope ?? null;
2181
- const lastSessionAt = rt?.lastSessionAt ?? meta.lastSessionAt ?? null;
2182
- const lastSessionAgo = rt?.lastSessionAgo ?? meta.lastSessionAgo ?? '-';
2183
- const lastActivityAt = rt?.lastActivityAt ?? meta.lastActivityAt ?? null;
2184
- const lastActivityAgo = rt?.lastActivityAgo ?? meta.lastActivityAgo ?? '-';
2185
- const lastInboundAt = rt?.lastInboundAt ?? meta.lastInboundAt ?? null;
2186
- const lastInboundAgo = rt?.lastInboundAgo ?? meta.lastInboundAgo ?? '-';
2187
- const lastOutboundAt = rt?.lastOutboundAt ?? meta.lastOutboundAt ?? null;
2188
- const lastOutboundAgo = rt?.lastOutboundAgo ?? meta.lastOutboundAgo ?? '-';
2189
- const diagnostics = rt?.diagnostics ?? meta.diagnostics ?? null;
2190
- // 右侧状态字段统一:离线时也显示 Status(避免出现 configured 文案)
2191
- const normalizedMode = rt?.mode === 'linked'
2192
- ? 'linked'
2193
- : 'Status';
2194
-
2195
- const displayName = resolveDefaultDisplayName(account?.name, account?.accountId);
2196
-
2197
- return {
2198
- accountId: account.accountId,
2199
- // default 名不可隐藏时,统一展示稳定默认值
2200
- name: displayName,
2201
- enabled: account.enabled !== false,
2202
- configured: true,
2203
- linked: Boolean(rt?.connected),
2204
- running: rt?.running ?? false,
2205
- connected: rt?.connected ?? false,
2206
- lastEventAt: rt?.lastEventAt ?? null,
2207
- lastError: rt?.lastError ?? null,
2208
- mode: normalizedMode,
2209
- pending,
2210
- deadLetter,
2211
- healthSummary: bridge.getStatusHeadline(account?.accountId),
2212
- lastSessionKey,
2213
- lastSessionScope,
2214
- lastSessionAt,
2215
- lastSessionAgo,
2216
- lastActivityAt,
2217
- lastActivityAgo,
2218
- lastInboundAt,
2219
- lastInboundAgo,
2220
- lastOutboundAt,
2221
- lastOutboundAgo,
2222
- diagnostics,
2223
- };
2224
- },
2225
- resolveAccountState: ({ enabled, configured, account, cfg, runtime }: any) => {
2226
- if (!enabled) return 'disabled';
2227
- const resolved = resolveAccount(cfg, account?.accountId);
2228
- if (!(resolved.enabled && configured)) return 'not configured';
2229
- const rt = runtime || bridge.getAccountRuntimeSnapshot(account?.accountId);
2230
- return rt?.connected ? 'linked' : 'configured';
2231
- },
2232
- },
2233
- gatewayMethods: [
2234
- 'bncr.connect',
2235
- 'bncr.inbound',
2236
- 'bncr.activity',
2237
- 'bncr.ack',
2238
- 'bncr.diagnostics',
2239
- 'bncr.file.init',
2240
- 'bncr.file.chunk',
2241
- 'bncr.file.complete',
2242
- 'bncr.file.abort',
2243
- 'bncr.file.ack',
2244
- ],
2245
- gateway: {
2246
- startAccount: bridge.channelStartAccount,
2247
- stopAccount: bridge.channelStopAccount,
2248
- },
2249
- };
2250
-
2251
- return plugin;
2252
- }
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import type {
5
+ OpenClawPluginApi,
6
+ OpenClawPluginServiceContext,
7
+ GatewayRequestHandlerOptions,
8
+ ChatType,
9
+ } from 'openclaw/plugin-sdk';
10
+ import {
11
+ createDefaultChannelRuntimeState,
12
+ setAccountEnabledInConfigSection,
13
+ applyAccountNameToChannelSection,
14
+ writeJsonFileAtomically,
15
+ readJsonFileWithFallback,
16
+ } from 'openclaw/plugin-sdk';
17
+ import { CHANNEL_ID, BNCR_DEFAULT_ACCOUNT_ID, normalizeAccountId, resolveDefaultDisplayName, resolveAccount, listAccountIds } from './core/accounts.js';
18
+ import type { BncrRoute, BncrConnection, OutboxEntry } from './core/types.js';
19
+ import {
20
+ parseRouteFromScope,
21
+ parseRouteFromDisplayScope,
22
+ formatDisplayScope,
23
+ isLowerHex,
24
+ routeScopeToHex,
25
+ parseRouteFromHexScope,
26
+ parseRouteLike,
27
+ parseLegacySessionKeyToStrict,
28
+ normalizeStoredSessionKey,
29
+ parseStrictBncrSessionKey,
30
+ normalizeInboundSessionKey,
31
+ withTaskSessionKey,
32
+ buildFallbackSessionKey,
33
+ routeKey,
34
+ } from './core/targets.js';
35
+ import { parseBncrInboundParams } from './messaging/inbound/parse.js';
36
+ import { dispatchBncrInbound } from './messaging/inbound/dispatch.js';
37
+ import { checkBncrMessageGate } from './messaging/inbound/gate.js';
38
+ import { sendBncrText, sendBncrMedia } from './messaging/outbound/send.js';
39
+ import { buildBncrMediaOutboundFrame, resolveBncrOutboundMessageType } from './messaging/outbound/media.js';
40
+ import { sendBncrReplyAction, deleteBncrMessageAction, reactBncrMessageAction, editBncrMessageAction } from './messaging/outbound/actions.js';
41
+ import {
42
+ buildIntegratedDiagnostics as buildIntegratedDiagnosticsFromRuntime,
43
+ buildStatusHeadlineFromRuntime,
44
+ buildStatusMetaFromRuntime,
45
+ buildAccountRuntimeSnapshot,
46
+ } from './core/status.js';
47
+ import { probeBncrAccount } from './core/probe.js';
48
+ import { BncrConfigSchema } from './core/config-schema.js';
49
+ import { resolveBncrChannelPolicy } from './core/policy.js';
50
+ import { buildBncrPermissionSummary } from './core/permissions.js';
51
+ const BRIDGE_VERSION = 2;
52
+ const BNCR_PUSH_EVENT = 'bncr.push';
53
+ const CONNECT_TTL_MS = 120_000;
54
+ const MAX_RETRY = 10;
55
+ const PUSH_DRAIN_INTERVAL_MS = 500;
56
+ const FILE_FORCE_CHUNK = true; // 统一走 WS 分块,保留 base64 仅作兜底
57
+ const FILE_INLINE_THRESHOLD = 5 * 1024 * 1024; // fallback 阈值(仅 FILE_FORCE_CHUNK=false 时生效)
58
+ const FILE_CHUNK_SIZE = 256 * 1024; // 256KB
59
+ const FILE_CHUNK_RETRY = 3;
60
+ const FILE_ACK_TIMEOUT_MS = 30_000;
61
+ const FILE_TRANSFER_ACK_TTL_MS = 30_000;
62
+ const FILE_TRANSFER_KEEP_MS = 6 * 60 * 60 * 1000;
63
+ let BNCR_DEBUG_VERBOSE = false; // 全局调试日志开关(默认关闭)
64
+
65
+ type FileSendTransferState = {
66
+ transferId: string;
67
+ accountId: string;
68
+ sessionKey: string;
69
+ route: BncrRoute;
70
+ fileName: string;
71
+ mimeType: string;
72
+ fileSize: number;
73
+ chunkSize: number;
74
+ totalChunks: number;
75
+ fileSha256: string;
76
+ startedAt: number;
77
+ status: 'init' | 'transferring' | 'completed' | 'aborted';
78
+ ackedChunks: Set<number>;
79
+ failedChunks: Map<number, string>;
80
+ completedPath?: string;
81
+ error?: string;
82
+ };
83
+
84
+ type FileRecvTransferState = {
85
+ transferId: string;
86
+ accountId: string;
87
+ sessionKey: string;
88
+ route: BncrRoute;
89
+ fileName: string;
90
+ mimeType: string;
91
+ fileSize: number;
92
+ chunkSize: number;
93
+ totalChunks: number;
94
+ fileSha256: string;
95
+ startedAt: number;
96
+ status: 'init' | 'transferring' | 'completed' | 'aborted';
97
+ bufferByChunk: Map<number, Buffer>;
98
+ receivedChunks: Set<number>;
99
+ completedPath?: string;
100
+ error?: string;
101
+ };
102
+
103
+ type PersistedState = {
104
+ outbox: OutboxEntry[];
105
+ deadLetter: OutboxEntry[];
106
+ sessionRoutes: Array<{
107
+ sessionKey: string;
108
+ accountId: string;
109
+ route: BncrRoute;
110
+ updatedAt: number;
111
+ }>;
112
+ lastSessionByAccount?: Array<{
113
+ accountId: string;
114
+ sessionKey: string;
115
+ scope: string;
116
+ updatedAt: number;
117
+ }>;
118
+ lastActivityByAccount?: Array<{
119
+ accountId: string;
120
+ updatedAt: number;
121
+ }>;
122
+ lastInboundByAccount?: Array<{
123
+ accountId: string;
124
+ updatedAt: number;
125
+ }>;
126
+ lastOutboundByAccount?: Array<{
127
+ accountId: string;
128
+ updatedAt: number;
129
+ }>;
130
+ };
131
+
132
+ function now() {
133
+ return Date.now();
134
+ }
135
+
136
+ function asString(v: unknown, fallback = ''): string {
137
+ if (typeof v === 'string') return v;
138
+ if (v == null) return fallback;
139
+ return String(v);
140
+ }
141
+
142
+
143
+ function backoffMs(retryCount: number): number {
144
+ // 1s,2s,4s,8s... capped by retry count checks
145
+ return Math.max(1_000, 1_000 * 2 ** Math.max(0, retryCount - 1));
146
+ }
147
+
148
+
149
+ function fileExtFromMime(mimeType?: string): string {
150
+ const mt = asString(mimeType || '').toLowerCase();
151
+ const map: Record<string, string> = {
152
+ 'image/jpeg': '.jpg',
153
+ 'image/jpg': '.jpg',
154
+ 'image/png': '.png',
155
+ 'image/webp': '.webp',
156
+ 'image/gif': '.gif',
157
+ 'video/mp4': '.mp4',
158
+ 'video/webm': '.webm',
159
+ 'video/quicktime': '.mov',
160
+ 'audio/mpeg': '.mp3',
161
+ 'audio/mp4': '.m4a',
162
+ 'application/pdf': '.pdf',
163
+ 'text/plain': '.txt',
164
+ };
165
+ return map[mt] || '';
166
+ }
167
+
168
+ function sanitizeFileName(rawName?: string, fallback = 'file.bin'): string {
169
+ const name = asString(rawName || '').trim();
170
+ const base = name || fallback;
171
+ const cleaned = base.replace(/[\\/:*?"<>|\x00-\x1F]+/g, '_').replace(/\s+/g, ' ').trim();
172
+ return cleaned || fallback;
173
+ }
174
+
175
+ function buildTimestampFileName(mimeType?: string): string {
176
+ const d = new Date();
177
+ const pad = (n: number) => String(n).padStart(2, '0');
178
+ const ts = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
179
+ const ext = fileExtFromMime(mimeType) || '.bin';
180
+ return `bncr_${ts}_${Math.random().toString(16).slice(2, 8)}${ext}`;
181
+ }
182
+
183
+ function resolveOutboundFileName(params: { mediaUrl?: string; fileName?: string; mimeType?: string }): string {
184
+ const mediaUrl = asString(params.mediaUrl || '').trim();
185
+ const mimeType = asString(params.mimeType || '').trim();
186
+
187
+ // 线上下载的文件,统一用时间戳命名(避免超长/无意义文件名)
188
+ if (/^https?:\/\//i.test(mediaUrl)) {
189
+ return buildTimestampFileName(mimeType);
190
+ }
191
+
192
+ const candidate = sanitizeFileName(params.fileName, 'file.bin');
193
+ if (candidate.length <= 80) return candidate;
194
+
195
+ // 超长文件名做裁剪,尽量保留扩展名
196
+ const ext = path.extname(candidate);
197
+ const stem = candidate.slice(0, Math.max(1, 80 - ext.length));
198
+ return `${stem}${ext}`;
199
+ }
200
+
201
+ class BncrBridgeRuntime {
202
+ private api: OpenClawPluginApi;
203
+ private statePath: string | null = null;
204
+ private bridgeId = `${process.pid}-${Math.random().toString(16).slice(2, 8)}`;
205
+
206
+ private connections = new Map<string, BncrConnection>(); // connectionKey -> connection
207
+ private activeConnectionByAccount = new Map<string, string>(); // accountId -> connectionKey
208
+ private outbox = new Map<string, OutboxEntry>(); // messageId -> entry
209
+ private deadLetter: OutboxEntry[] = [];
210
+
211
+ private sessionRoutes = new Map<string, { accountId: string; route: BncrRoute; updatedAt: number }>();
212
+ private routeAliases = new Map<string, { accountId: string; route: BncrRoute; updatedAt: number }>();
213
+
214
+ private recentInbound = new Map<string, number>();
215
+ private lastSessionByAccount = new Map<string, { sessionKey: string; scope: string; updatedAt: number }>();
216
+ private lastActivityByAccount = new Map<string, number>();
217
+ private lastInboundByAccount = new Map<string, number>();
218
+ private lastOutboundByAccount = new Map<string, number>();
219
+
220
+ // 内置健康/回归计数(替代独立脚本)
221
+ private startedAt = now();
222
+ private connectEventsByAccount = new Map<string, number>();
223
+ private inboundEventsByAccount = new Map<string, number>();
224
+ private activityEventsByAccount = new Map<string, number>();
225
+ private ackEventsByAccount = new Map<string, number>();
226
+
227
+ private saveTimer: NodeJS.Timeout | null = null;
228
+ private pushTimer: NodeJS.Timeout | null = null;
229
+ private pushDrainRunningAccounts = new Set<string>();
230
+ private waiters = new Map<string, Array<() => void>>();
231
+ private gatewayContext: GatewayRequestHandlerOptions['context'] | null = null;
232
+
233
+ // 文件互传状态(V1:尽力而为,重连不续传)
234
+ private fileSendTransfers = new Map<string, FileSendTransferState>(); // OpenClaw -> Bncr(服务端发起)
235
+ private fileRecvTransfers = new Map<string, FileRecvTransferState>(); // Bncr -> OpenClaw(客户端发起)
236
+ private fileAckWaiters = new Map<string, {
237
+ resolve: (payload: Record<string, unknown>) => void;
238
+ reject: (err: Error) => void;
239
+ timer: NodeJS.Timeout;
240
+ }>();
241
+
242
+ constructor(api: OpenClawPluginApi) {
243
+ this.api = api;
244
+ }
245
+
246
+ isDebugEnabled(): boolean {
247
+ try {
248
+ const cfg = (this.api.runtime.config?.get?.() as any) || {};
249
+ return Boolean(cfg?.channels?.[CHANNEL_ID]?.debug?.verbose);
250
+ } catch {
251
+ return false;
252
+ }
253
+ }
254
+
255
+ startService = async (ctx: OpenClawPluginServiceContext, debug?: boolean) => {
256
+ this.statePath = path.join(ctx.stateDir, 'bncr-bridge-state.json');
257
+ await this.loadState();
258
+ if (typeof debug === 'boolean') BNCR_DEBUG_VERBOSE = debug;
259
+ const bootDiag = this.buildIntegratedDiagnostics(BNCR_DEFAULT_ACCOUNT_ID);
260
+ if (BNCR_DEBUG_VERBOSE) {
261
+ this.api.logger.info(`bncr-channel service started (bridge=${this.bridgeId} diag.ok=${bootDiag.regression.ok} routes=${bootDiag.regression.totalKnownRoutes} pending=${bootDiag.health.pending} dead=${bootDiag.health.deadLetter} debug=${BNCR_DEBUG_VERBOSE})`);
262
+ }
263
+ };
264
+
265
+ stopService = async () => {
266
+ if (this.pushTimer) {
267
+ clearTimeout(this.pushTimer);
268
+ this.pushTimer = null;
269
+ }
270
+ await this.flushState();
271
+ if (BNCR_DEBUG_VERBOSE) {
272
+ this.api.logger.info('bncr-channel service stopped');
273
+ }
274
+ };
275
+
276
+ private scheduleSave() {
277
+ if (this.saveTimer) return;
278
+ this.saveTimer = setTimeout(() => {
279
+ this.saveTimer = null;
280
+ void this.flushState();
281
+ }, 300);
282
+ }
283
+
284
+ private incrementCounter(map: Map<string, number>, accountId: string) {
285
+ const acc = normalizeAccountId(accountId);
286
+ map.set(acc, (map.get(acc) || 0) + 1);
287
+ }
288
+
289
+ private getCounter(map: Map<string, number>, accountId: string): number {
290
+ return map.get(normalizeAccountId(accountId)) || 0;
291
+ }
292
+
293
+ private syncDebugFlag() {
294
+ const next = this.isDebugEnabled();
295
+ if (next !== BNCR_DEBUG_VERBOSE) {
296
+ BNCR_DEBUG_VERBOSE = next;
297
+ this.api.logger.info?.(`[bncr-debug] verbose=${BNCR_DEBUG_VERBOSE}`);
298
+ }
299
+ }
300
+
301
+ private countInvalidOutboxSessionKeys(accountId: string): number {
302
+ const acc = normalizeAccountId(accountId);
303
+ let count = 0;
304
+ for (const entry of this.outbox.values()) {
305
+ if (entry.accountId !== acc) continue;
306
+ if (!parseStrictBncrSessionKey(entry.sessionKey)) count += 1;
307
+ }
308
+ return count;
309
+ }
310
+
311
+ private countLegacyAccountResidue(accountId: string): number {
312
+ const acc = normalizeAccountId(accountId);
313
+ const mismatched = (raw?: string | null) => asString(raw || '').trim() && normalizeAccountId(raw) !== acc;
314
+
315
+ let count = 0;
316
+
317
+ for (const entry of this.outbox.values()) {
318
+ if (mismatched(entry.accountId)) count += 1;
319
+ }
320
+ for (const entry of this.deadLetter) {
321
+ if (mismatched(entry.accountId)) count += 1;
322
+ }
323
+ for (const info of this.sessionRoutes.values()) {
324
+ if (mismatched(info.accountId)) count += 1;
325
+ }
326
+ for (const key of this.lastSessionByAccount.keys()) {
327
+ if (mismatched(key)) count += 1;
328
+ }
329
+ for (const key of this.lastActivityByAccount.keys()) {
330
+ if (mismatched(key)) count += 1;
331
+ }
332
+ for (const key of this.lastInboundByAccount.keys()) {
333
+ if (mismatched(key)) count += 1;
334
+ }
335
+ for (const key of this.lastOutboundByAccount.keys()) {
336
+ if (mismatched(key)) count += 1;
337
+ }
338
+
339
+ return count;
340
+ }
341
+
342
+ private buildIntegratedDiagnostics(accountId: string) {
343
+ const acc = normalizeAccountId(accountId);
344
+ return buildIntegratedDiagnosticsFromRuntime({
345
+ accountId: acc,
346
+ connected: this.isOnline(acc),
347
+ pending: Array.from(this.outbox.values()).filter((v) => v.accountId === acc).length,
348
+ deadLetter: this.deadLetter.filter((v) => v.accountId === acc).length,
349
+ activeConnections: this.activeConnectionCount(acc),
350
+ connectEvents: this.getCounter(this.connectEventsByAccount, acc),
351
+ inboundEvents: this.getCounter(this.inboundEventsByAccount, acc),
352
+ activityEvents: this.getCounter(this.activityEventsByAccount, acc),
353
+ ackEvents: this.getCounter(this.ackEventsByAccount, acc),
354
+ startedAt: this.startedAt,
355
+ lastSession: this.lastSessionByAccount.get(acc) || null,
356
+ lastActivityAt: this.lastActivityByAccount.get(acc) || null,
357
+ lastInboundAt: this.lastInboundByAccount.get(acc) || null,
358
+ lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
359
+ sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc).length,
360
+ invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
361
+ legacyAccountResidue: this.countLegacyAccountResidue(acc),
362
+ channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
363
+ });
364
+ }
365
+
366
+ private async loadState() {
367
+ if (!this.statePath) return;
368
+ const loaded = await readJsonFileWithFallback(this.statePath, {
369
+ outbox: [],
370
+ deadLetter: [],
371
+ sessionRoutes: [],
372
+ });
373
+ const data = loaded.value as PersistedState;
374
+
375
+ this.outbox.clear();
376
+ for (const entry of data.outbox || []) {
377
+ if (!entry?.messageId) continue;
378
+ const accountId = normalizeAccountId(entry.accountId);
379
+ const sessionKey = asString(entry.sessionKey || '').trim();
380
+ const normalized = normalizeStoredSessionKey(sessionKey);
381
+ if (!normalized) continue;
382
+
383
+ const route = parseRouteLike(entry.route) || normalized.route;
384
+ const payload = (entry.payload && typeof entry.payload === 'object') ? { ...entry.payload } : {};
385
+ (payload as any).sessionKey = normalized.sessionKey;
386
+ (payload as any).platform = route.platform;
387
+ (payload as any).groupId = route.groupId;
388
+ (payload as any).userId = route.userId;
389
+
390
+ const migratedEntry: OutboxEntry = {
391
+ ...entry,
392
+ accountId,
393
+ sessionKey: normalized.sessionKey,
394
+ route,
395
+ payload,
396
+ createdAt: Number(entry.createdAt || now()),
397
+ retryCount: Number(entry.retryCount || 0),
398
+ nextAttemptAt: Number(entry.nextAttemptAt || now()),
399
+ lastAttemptAt: entry.lastAttemptAt ? Number(entry.lastAttemptAt) : undefined,
400
+ lastError: entry.lastError ? asString(entry.lastError) : undefined,
401
+ };
402
+
403
+ this.outbox.set(migratedEntry.messageId, migratedEntry);
404
+ }
405
+
406
+ this.deadLetter = [];
407
+ for (const entry of Array.isArray(data.deadLetter) ? data.deadLetter : []) {
408
+ if (!entry?.messageId) continue;
409
+ const accountId = normalizeAccountId(entry.accountId);
410
+ const sessionKey = asString(entry.sessionKey || '').trim();
411
+ const normalized = normalizeStoredSessionKey(sessionKey);
412
+ if (!normalized) continue;
413
+
414
+ const route = parseRouteLike(entry.route) || normalized.route;
415
+ const payload = (entry.payload && typeof entry.payload === 'object') ? { ...entry.payload } : {};
416
+ (payload as any).sessionKey = normalized.sessionKey;
417
+ (payload as any).platform = route.platform;
418
+ (payload as any).groupId = route.groupId;
419
+ (payload as any).userId = route.userId;
420
+
421
+ this.deadLetter.push({
422
+ ...entry,
423
+ accountId,
424
+ sessionKey: normalized.sessionKey,
425
+ route,
426
+ payload,
427
+ createdAt: Number(entry.createdAt || now()),
428
+ retryCount: Number(entry.retryCount || 0),
429
+ nextAttemptAt: Number(entry.nextAttemptAt || now()),
430
+ lastAttemptAt: entry.lastAttemptAt ? Number(entry.lastAttemptAt) : undefined,
431
+ lastError: entry.lastError ? asString(entry.lastError) : undefined,
432
+ });
433
+ }
434
+
435
+ this.sessionRoutes.clear();
436
+ this.routeAliases.clear();
437
+ for (const item of data.sessionRoutes || []) {
438
+ const normalized = normalizeStoredSessionKey(asString(item?.sessionKey || ''));
439
+ if (!normalized) continue;
440
+
441
+ const route = parseRouteLike(item?.route) || normalized.route;
442
+ const accountId = normalizeAccountId(item?.accountId);
443
+ const updatedAt = Number(item?.updatedAt || now());
444
+
445
+ const info = {
446
+ accountId,
447
+ route,
448
+ updatedAt,
449
+ };
450
+
451
+ this.sessionRoutes.set(normalized.sessionKey, info);
452
+ this.routeAliases.set(routeKey(accountId, route), info);
453
+ }
454
+
455
+ this.lastSessionByAccount.clear();
456
+ for (const item of data.lastSessionByAccount || []) {
457
+ const accountId = normalizeAccountId(item?.accountId);
458
+ const normalized = normalizeStoredSessionKey(asString(item?.sessionKey || ''));
459
+ const updatedAt = Number(item?.updatedAt || 0);
460
+ if (!normalized || !Number.isFinite(updatedAt) || updatedAt <= 0) continue;
461
+
462
+ this.lastSessionByAccount.set(accountId, {
463
+ sessionKey: normalized.sessionKey,
464
+ // 展示统一为 Bncr-platform:group:user
465
+ scope: formatDisplayScope(normalized.route),
466
+ updatedAt,
467
+ });
468
+ }
469
+
470
+ this.lastActivityByAccount.clear();
471
+ for (const item of data.lastActivityByAccount || []) {
472
+ const accountId = normalizeAccountId(item?.accountId);
473
+ const updatedAt = Number(item?.updatedAt || 0);
474
+ if (!Number.isFinite(updatedAt) || updatedAt <= 0) continue;
475
+ this.lastActivityByAccount.set(accountId, updatedAt);
476
+ }
477
+
478
+ this.lastInboundByAccount.clear();
479
+ for (const item of data.lastInboundByAccount || []) {
480
+ const accountId = normalizeAccountId(item?.accountId);
481
+ const updatedAt = Number(item?.updatedAt || 0);
482
+ if (!Number.isFinite(updatedAt) || updatedAt <= 0) continue;
483
+ this.lastInboundByAccount.set(accountId, updatedAt);
484
+ }
485
+
486
+ this.lastOutboundByAccount.clear();
487
+ for (const item of data.lastOutboundByAccount || []) {
488
+ const accountId = normalizeAccountId(item?.accountId);
489
+ const updatedAt = Number(item?.updatedAt || 0);
490
+ if (!Number.isFinite(updatedAt) || updatedAt <= 0) continue;
491
+ this.lastOutboundByAccount.set(accountId, updatedAt);
492
+ }
493
+
494
+ // 兼容旧状态文件:若尚未持久化 lastSession*/lastActivity*,从 sessionRoutes 回填。
495
+ if (this.lastSessionByAccount.size === 0 && this.sessionRoutes.size > 0) {
496
+ for (const [sessionKey, info] of this.sessionRoutes.entries()) {
497
+ const acc = normalizeAccountId(info.accountId);
498
+ const updatedAt = Number(info.updatedAt || 0);
499
+ if (!Number.isFinite(updatedAt) || updatedAt <= 0) continue;
500
+
501
+ const current = this.lastSessionByAccount.get(acc);
502
+ if (!current || updatedAt >= current.updatedAt) {
503
+ this.lastSessionByAccount.set(acc, {
504
+ sessionKey,
505
+ // 回填时统一展示为 Bncr-platform:group:user
506
+ scope: formatDisplayScope(info.route),
507
+ updatedAt,
508
+ });
509
+ }
510
+
511
+ const lastAct = this.lastActivityByAccount.get(acc) || 0;
512
+ if (updatedAt > lastAct) this.lastActivityByAccount.set(acc, updatedAt);
513
+
514
+ const lastIn = this.lastInboundByAccount.get(acc) || 0;
515
+ if (updatedAt > lastIn) this.lastInboundByAccount.set(acc, updatedAt);
516
+ }
517
+ }
518
+ }
519
+
520
+ private async flushState() {
521
+ if (!this.statePath) return;
522
+
523
+ const sessionRoutes = Array.from(this.sessionRoutes.entries())
524
+ .map(([sessionKey, v]) => ({
525
+ sessionKey,
526
+ accountId: v.accountId,
527
+ route: v.route,
528
+ updatedAt: v.updatedAt,
529
+ }))
530
+ .slice(-1000);
531
+
532
+ const data: PersistedState = {
533
+ outbox: Array.from(this.outbox.values()),
534
+ deadLetter: this.deadLetter.slice(-1000),
535
+ sessionRoutes,
536
+ lastSessionByAccount: Array.from(this.lastSessionByAccount.entries()).map(([accountId, v]) => ({
537
+ accountId,
538
+ sessionKey: v.sessionKey,
539
+ scope: v.scope,
540
+ updatedAt: v.updatedAt,
541
+ })),
542
+ lastActivityByAccount: Array.from(this.lastActivityByAccount.entries()).map(([accountId, updatedAt]) => ({
543
+ accountId,
544
+ updatedAt,
545
+ })),
546
+ lastInboundByAccount: Array.from(this.lastInboundByAccount.entries()).map(([accountId, updatedAt]) => ({
547
+ accountId,
548
+ updatedAt,
549
+ })),
550
+ lastOutboundByAccount: Array.from(this.lastOutboundByAccount.entries()).map(([accountId, updatedAt]) => ({
551
+ accountId,
552
+ updatedAt,
553
+ })),
554
+ };
555
+
556
+ await writeJsonFileAtomically(this.statePath, data);
557
+ }
558
+
559
+ private wakeAccountWaiters(accountId: string) {
560
+ const key = normalizeAccountId(accountId);
561
+ const waits = this.waiters.get(key);
562
+ if (!waits?.length) return;
563
+ this.waiters.delete(key);
564
+ for (const resolve of waits) resolve();
565
+ }
566
+
567
+ private rememberGatewayContext(context: GatewayRequestHandlerOptions['context']) {
568
+ if (context) this.gatewayContext = context;
569
+ }
570
+
571
+ private resolvePushConnIds(accountId: string): Set<string> {
572
+ const acc = normalizeAccountId(accountId);
573
+ const t = now();
574
+ const connIds = new Set<string>();
575
+
576
+ const primaryKey = this.activeConnectionByAccount.get(acc);
577
+ if (primaryKey) {
578
+ const primary = this.connections.get(primaryKey);
579
+ if (primary?.connId && t - primary.lastSeenAt <= CONNECT_TTL_MS) {
580
+ connIds.add(primary.connId);
581
+ }
582
+ }
583
+
584
+ if (connIds.size > 0) return connIds;
585
+
586
+ for (const c of this.connections.values()) {
587
+ if (c.accountId !== acc) continue;
588
+ if (!c.connId) continue;
589
+ if (t - c.lastSeenAt > CONNECT_TTL_MS) continue;
590
+ connIds.add(c.connId);
591
+ }
592
+
593
+ return connIds;
594
+ }
595
+
596
+ private tryPushEntry(entry: OutboxEntry): boolean {
597
+ const ctx = this.gatewayContext;
598
+ if (!ctx) {
599
+ if (BNCR_DEBUG_VERBOSE) {
600
+ this.api.logger.info?.(
601
+ `[bncr-outbox-push-skip] ${JSON.stringify({
602
+ messageId: entry.messageId,
603
+ accountId: entry.accountId,
604
+ reason: 'no-gateway-context',
605
+ })}`,
606
+ );
607
+ }
608
+ return false;
609
+ }
610
+
611
+ const connIds = this.resolvePushConnIds(entry.accountId);
612
+ if (!connIds.size) {
613
+ if (BNCR_DEBUG_VERBOSE) {
614
+ this.api.logger.info?.(
615
+ `[bncr-outbox-push-skip] ${JSON.stringify({
616
+ messageId: entry.messageId,
617
+ accountId: entry.accountId,
618
+ reason: 'no-active-connection',
619
+ })}`,
620
+ );
621
+ }
622
+ return false;
623
+ }
624
+
625
+ try {
626
+ const payload = {
627
+ ...entry.payload,
628
+ idempotencyKey: entry.messageId,
629
+ };
630
+
631
+ ctx.broadcastToConnIds(BNCR_PUSH_EVENT, payload, connIds);
632
+ if (BNCR_DEBUG_VERBOSE) {
633
+ this.api.logger.info?.(
634
+ `[bncr-outbox-push-ok] ${JSON.stringify({
635
+ messageId: entry.messageId,
636
+ accountId: entry.accountId,
637
+ connIds: Array.from(connIds),
638
+ event: BNCR_PUSH_EVENT,
639
+ })}`,
640
+ );
641
+ }
642
+ this.outbox.delete(entry.messageId);
643
+ this.lastOutboundByAccount.set(entry.accountId, now());
644
+ this.markActivity(entry.accountId);
645
+ this.scheduleSave();
646
+ return true;
647
+ } catch (error) {
648
+ entry.lastError = asString((error as any)?.message || error || 'push-error');
649
+ this.outbox.set(entry.messageId, entry);
650
+ if (BNCR_DEBUG_VERBOSE) {
651
+ this.api.logger.info?.(
652
+ `[bncr-outbox-push-fail] ${JSON.stringify({
653
+ messageId: entry.messageId,
654
+ accountId: entry.accountId,
655
+ error: entry.lastError,
656
+ })}`,
657
+ );
658
+ }
659
+ return false;
660
+ }
661
+ }
662
+
663
+ private schedulePushDrain(delayMs = 0) {
664
+ if (this.pushTimer) return;
665
+ const delay = Math.max(0, Math.min(Number(delayMs || 0), 30_000));
666
+ this.pushTimer = setTimeout(() => {
667
+ this.pushTimer = null;
668
+ void this.flushPushQueue();
669
+ }, delay);
670
+ }
671
+
672
+ private async flushPushQueue(accountId?: string): Promise<void> {
673
+ const filterAcc = accountId ? normalizeAccountId(accountId) : null;
674
+ const targetAccounts = filterAcc
675
+ ? [filterAcc]
676
+ : Array.from(new Set(Array.from(this.outbox.values()).map((entry) => normalizeAccountId(entry.accountId))));
677
+ if (BNCR_DEBUG_VERBOSE) {
678
+ this.api.logger.info?.(
679
+ `[bncr-outbox-flush] ${JSON.stringify({
680
+ bridge: this.bridgeId,
681
+ accountId: filterAcc,
682
+ targetAccounts,
683
+ outboxSize: this.outbox.size,
684
+ })}`,
685
+ );
686
+ }
687
+
688
+ let globalNextDelay: number | null = null;
689
+
690
+ for (const acc of targetAccounts) {
691
+ if (!acc || this.pushDrainRunningAccounts.has(acc)) continue;
692
+ const online = this.isOnline(acc);
693
+ if (BNCR_DEBUG_VERBOSE) {
694
+ this.api.logger.info?.(
695
+ `[bncr-outbox-online] ${JSON.stringify({
696
+ bridge: this.bridgeId,
697
+ accountId: acc,
698
+ online,
699
+ connections: Array.from(this.connections.values()).map((c) => ({
700
+ accountId: c.accountId,
701
+ connId: c.connId,
702
+ clientId: c.clientId,
703
+ lastSeenAt: c.lastSeenAt,
704
+ })),
705
+ })}`,
706
+ );
707
+ }
708
+ if (!online) {
709
+ const ctx = this.gatewayContext;
710
+ const directConnIds = Array.from(this.connections.values())
711
+ .filter((c) => normalizeAccountId(c.accountId) === acc && c.connId)
712
+ .map((c) => c.connId as string);
713
+
714
+ if (BNCR_DEBUG_VERBOSE) {
715
+ this.api.logger.info?.(
716
+ `[bncr-outbox-direct-push] ${JSON.stringify({
717
+ bridge: this.bridgeId,
718
+ accountId: acc,
719
+ outboxSize: this.outbox.size,
720
+ hasGatewayContext: Boolean(ctx),
721
+ connCount: directConnIds.length,
722
+ })}`,
723
+ );
724
+ }
725
+
726
+ if (!ctx) {
727
+ if (BNCR_DEBUG_VERBOSE) {
728
+ this.api.logger.info?.(
729
+ `[bncr-outbox-direct-push-skip] ${JSON.stringify({
730
+ bridge: this.bridgeId,
731
+ accountId: acc,
732
+ reason: 'no-gateway-context',
733
+ })}`,
734
+ );
735
+ }
736
+ continue;
737
+ }
738
+
739
+ if (!directConnIds.length) {
740
+ if (BNCR_DEBUG_VERBOSE) {
741
+ this.api.logger.info?.(
742
+ `[bncr-outbox-direct-push-skip] ${JSON.stringify({
743
+ accountId: acc,
744
+ reason: 'no-connection',
745
+ })}`,
746
+ );
747
+ }
748
+ continue;
749
+ }
750
+
751
+ const directPayloads = this.collectDue(acc, 50);
752
+ if (!directPayloads.length) continue;
753
+
754
+ try {
755
+ ctx.broadcastToConnIds(BNCR_PUSH_EVENT, {
756
+ forcePush: true,
757
+ items: directPayloads,
758
+ }, new Set(directConnIds));
759
+
760
+ const pushedIds = directPayloads
761
+ .map((item: any) => asString(item?.messageId || item?.idempotencyKey || '').trim())
762
+ .filter(Boolean);
763
+ for (const id of pushedIds) this.outbox.delete(id);
764
+ if (pushedIds.length) this.scheduleSave();
765
+
766
+ if (BNCR_DEBUG_VERBOSE) {
767
+ this.api.logger.info?.(
768
+ `[bncr-outbox-direct-push-ok] ${JSON.stringify({
769
+ bridge: this.bridgeId,
770
+ accountId: acc,
771
+ count: directPayloads.length,
772
+ connCount: directConnIds.length,
773
+ dropped: pushedIds.length,
774
+ })}`,
775
+ );
776
+ }
777
+ } catch (error) {
778
+ if (BNCR_DEBUG_VERBOSE) {
779
+ this.api.logger.info?.(
780
+ `[bncr-outbox-direct-push-fail] ${JSON.stringify({
781
+ accountId: acc,
782
+ error: asString((error as any)?.message || error || 'direct-push-error'),
783
+ })}`,
784
+ );
785
+ }
786
+ }
787
+ continue;
788
+ }
789
+
790
+ this.pushDrainRunningAccounts.add(acc);
791
+ try {
792
+ let localNextDelay: number | null = null;
793
+
794
+ while (true) {
795
+ const t = now();
796
+ const entries = Array.from(this.outbox.values())
797
+ .filter((entry) => normalizeAccountId(entry.accountId) === acc)
798
+ .sort((a, b) => a.createdAt - b.createdAt);
799
+
800
+ if (!entries.length) break;
801
+ if (!this.isOnline(acc)) break;
802
+
803
+ const entry = entries.find((item) => item.nextAttemptAt <= t);
804
+ if (!entry) {
805
+ const wait = Math.max(0, entries[0].nextAttemptAt - t);
806
+ localNextDelay = localNextDelay == null ? wait : Math.min(localNextDelay, wait);
807
+ break;
808
+ }
809
+
810
+ const pushed = this.tryPushEntry(entry);
811
+ if (pushed) {
812
+ this.scheduleSave();
813
+ await this.sleepMs(PUSH_DRAIN_INTERVAL_MS);
814
+ continue;
815
+ }
816
+
817
+ const nextAttempt = entry.retryCount + 1;
818
+ if (nextAttempt > MAX_RETRY) {
819
+ this.moveToDeadLetter(entry, entry.lastError || 'push-retry-limit');
820
+ continue;
821
+ }
822
+
823
+ entry.retryCount = nextAttempt;
824
+ entry.lastAttemptAt = t;
825
+ entry.nextAttemptAt = t + backoffMs(nextAttempt);
826
+ entry.lastError = entry.lastError || 'push-retry';
827
+ this.outbox.set(entry.messageId, entry);
828
+ this.scheduleSave();
829
+
830
+ const wait = Math.max(0, entry.nextAttemptAt - t);
831
+ localNextDelay = localNextDelay == null ? wait : Math.min(localNextDelay, wait);
832
+ break;
833
+ }
834
+
835
+ if (localNextDelay != null) {
836
+ globalNextDelay = globalNextDelay == null ? localNextDelay : Math.min(globalNextDelay, localNextDelay);
837
+ }
838
+ } finally {
839
+ this.pushDrainRunningAccounts.delete(acc);
840
+ }
841
+ }
842
+
843
+ if (globalNextDelay != null) this.schedulePushDrain(globalNextDelay);
844
+ }
845
+
846
+ private async waitForOutbound(accountId: string, waitMs: number): Promise<void> {
847
+ const key = normalizeAccountId(accountId);
848
+ const timeoutMs = Math.max(0, Math.min(waitMs, 25_000));
849
+ if (!timeoutMs) return;
850
+
851
+ await new Promise<void>((resolve) => {
852
+ const timer = setTimeout(() => {
853
+ const arr = this.waiters.get(key) || [];
854
+ this.waiters.set(
855
+ key,
856
+ arr.filter((fn) => fn !== done),
857
+ );
858
+ resolve();
859
+ }, timeoutMs);
860
+
861
+ const done = () => {
862
+ clearTimeout(timer);
863
+ resolve();
864
+ };
865
+
866
+ const arr = this.waiters.get(key) || [];
867
+ arr.push(done);
868
+ this.waiters.set(key, arr);
869
+ });
870
+ }
871
+
872
+ private connectionKey(accountId: string, clientId?: string): string {
873
+ const acc = normalizeAccountId(accountId);
874
+ const cid = asString(clientId || '').trim();
875
+ return `${acc}::${cid || 'default'}`;
876
+ }
877
+
878
+ private gcTransientState() {
879
+ const t = now();
880
+
881
+ // 清理过期连接
882
+ const staleBefore = t - CONNECT_TTL_MS * 2;
883
+ for (const [key, c] of this.connections.entries()) {
884
+ if (c.lastSeenAt < staleBefore) {
885
+ if (BNCR_DEBUG_VERBOSE) {
886
+ this.api.logger.info?.(
887
+ `[bncr-conn-gc] ${JSON.stringify({
888
+ bridge: this.bridgeId,
889
+ key,
890
+ accountId: c.accountId,
891
+ connId: c.connId,
892
+ clientId: c.clientId,
893
+ lastSeenAt: c.lastSeenAt,
894
+ staleBefore,
895
+ })}`,
896
+ );
897
+ }
898
+ this.connections.delete(key);
899
+ }
900
+ }
901
+
902
+ // 清理去重窗口(90s)
903
+ const dedupWindowMs = 90_000;
904
+ for (const [key, ts] of this.recentInbound.entries()) {
905
+ if (t - ts > dedupWindowMs) this.recentInbound.delete(key);
906
+ }
907
+
908
+ this.cleanupFileTransfers();
909
+ }
910
+
911
+ private cleanupFileTransfers() {
912
+ const t = now();
913
+ for (const [id, st] of this.fileSendTransfers.entries()) {
914
+ if (t - st.startedAt > FILE_TRANSFER_KEEP_MS) this.fileSendTransfers.delete(id);
915
+ }
916
+ for (const [id, st] of this.fileRecvTransfers.entries()) {
917
+ if (t - st.startedAt > FILE_TRANSFER_KEEP_MS) this.fileRecvTransfers.delete(id);
918
+ }
919
+ }
920
+
921
+ private markSeen(accountId: string, connId: string, clientId?: string) {
922
+ this.gcTransientState();
923
+
924
+ const acc = normalizeAccountId(accountId);
925
+ const key = this.connectionKey(acc, clientId);
926
+ const t = now();
927
+ const prev = this.connections.get(key);
928
+
929
+ const nextConn: BncrConnection = {
930
+ accountId: acc,
931
+ connId,
932
+ clientId: asString(clientId || '').trim() || undefined,
933
+ connectedAt: prev?.connectedAt || t,
934
+ lastSeenAt: t,
935
+ };
936
+
937
+ this.connections.set(key, nextConn);
938
+ if (BNCR_DEBUG_VERBOSE) {
939
+ this.api.logger.info?.(
940
+ `[bncr-conn-seen] ${JSON.stringify({
941
+ bridge: this.bridgeId,
942
+ accountId: acc,
943
+ connId,
944
+ clientId: nextConn.clientId,
945
+ connectedAt: nextConn.connectedAt,
946
+ lastSeenAt: nextConn.lastSeenAt,
947
+ })}`,
948
+ );
949
+ }
950
+
951
+ const current = this.activeConnectionByAccount.get(acc);
952
+ if (!current) {
953
+ this.activeConnectionByAccount.set(acc, key);
954
+ return;
955
+ }
956
+
957
+ const curConn = this.connections.get(current);
958
+ if (!curConn || t - curConn.lastSeenAt > CONNECT_TTL_MS || nextConn.connectedAt >= curConn.connectedAt) {
959
+ this.activeConnectionByAccount.set(acc, key);
960
+ }
961
+ }
962
+
963
+ private isOnline(accountId: string): boolean {
964
+ const acc = normalizeAccountId(accountId);
965
+ const t = now();
966
+ for (const c of this.connections.values()) {
967
+ if (c.accountId !== acc) continue;
968
+ if (t - c.lastSeenAt <= CONNECT_TTL_MS) return true;
969
+ }
970
+ return false;
971
+ }
972
+
973
+ private activeConnectionCount(accountId: string): number {
974
+ const acc = normalizeAccountId(accountId);
975
+ const t = now();
976
+ let n = 0;
977
+ for (const c of this.connections.values()) {
978
+ if (c.accountId !== acc) continue;
979
+ if (t - c.lastSeenAt <= CONNECT_TTL_MS) n += 1;
980
+ }
981
+ return n;
982
+ }
983
+
984
+ private isPrimaryConnection(accountId: string, clientId?: string): boolean {
985
+ const acc = normalizeAccountId(accountId);
986
+ const key = this.connectionKey(acc, clientId);
987
+ const primary = this.activeConnectionByAccount.get(acc);
988
+ if (!primary) return true;
989
+ return primary === key;
990
+ }
991
+
992
+ private markInboundDedupSeen(key: string): boolean {
993
+ const t = now();
994
+ const last = this.recentInbound.get(key);
995
+ this.recentInbound.set(key, t);
996
+
997
+ // 90s 内重复包直接丢弃
998
+ return typeof last === 'number' && t - last <= 90_000;
999
+ }
1000
+
1001
+ private rememberSessionRoute(sessionKey: string, accountId: string, route: BncrRoute) {
1002
+ const key = asString(sessionKey).trim();
1003
+ if (!key) return;
1004
+
1005
+ const acc = normalizeAccountId(accountId);
1006
+ const t = now();
1007
+ const info = { accountId: acc, route, updatedAt: t };
1008
+
1009
+ this.sessionRoutes.set(key, info);
1010
+ // 同步维护旧格式与新格式,便于平滑切换
1011
+ this.sessionRoutes.set(buildFallbackSessionKey(route), info);
1012
+
1013
+ this.routeAliases.set(routeKey(acc, route), info);
1014
+ this.lastSessionByAccount.set(acc, {
1015
+ sessionKey: key,
1016
+ // 状态展示统一为 Bncr-platform:group:user
1017
+ scope: formatDisplayScope(route),
1018
+ updatedAt: t,
1019
+ });
1020
+ this.markActivity(acc, t);
1021
+ this.scheduleSave();
1022
+ }
1023
+
1024
+ private resolveRouteBySession(sessionKey: string, accountId: string): BncrRoute | null {
1025
+ const key = asString(sessionKey).trim();
1026
+ const hit = this.sessionRoutes.get(key);
1027
+ if (hit && normalizeAccountId(accountId) === normalizeAccountId(hit.accountId)) {
1028
+ return hit.route;
1029
+ }
1030
+
1031
+ const parsed = parseStrictBncrSessionKey(key);
1032
+ if (!parsed) return null;
1033
+
1034
+ const alias = this.routeAliases.get(routeKey(normalizeAccountId(accountId), parsed.route));
1035
+ return alias?.route || parsed.route;
1036
+ }
1037
+
1038
+ // 严谨目标解析:
1039
+ // 1) 标准 to 仅认 Bncr:<platform>:<groupId>:<userId> / Bncr:<platform>:<userId>
1040
+ // 2) 仍接受 strict sessionKey 作为内部兼容输入
1041
+ // 3) 其他旧格式直接失败,并输出标准格式提示日志
1042
+ private resolveVerifiedTarget(rawTarget: string, accountId: string): { sessionKey: string; route: BncrRoute; displayScope: string } {
1043
+ const acc = normalizeAccountId(accountId);
1044
+ const raw = asString(rawTarget).trim();
1045
+ if (!raw) throw new Error('bncr invalid target(empty)');
1046
+
1047
+ if (BNCR_DEBUG_VERBOSE) {
1048
+ this.api.logger.info?.(`[bncr-target-incoming] raw=${raw} accountId=${acc}`);
1049
+ }
1050
+
1051
+ let route: BncrRoute | null = null;
1052
+
1053
+ const strict = parseStrictBncrSessionKey(raw);
1054
+ if (strict) {
1055
+ route = strict.route;
1056
+ } else {
1057
+ route = parseRouteFromDisplayScope(raw) || this.resolveRouteBySession(raw, acc);
1058
+ }
1059
+
1060
+ if (!route) {
1061
+ this.api.logger.warn?.(
1062
+ `[bncr-target-invalid] raw=${raw} accountId=${acc} reason=unparseable-or-unknown standardTo=Bncr:<platform>:<groupId>:<userId>|Bncr:<platform>:<userId> standardSessionKey=agent:main:bncr:direct:<hex(scope)>`,
1063
+ );
1064
+ throw new Error(`bncr invalid target(standard: Bncr:<platform>:<groupId>:<userId> | Bncr:<platform>:<userId>): ${raw}`);
1065
+ }
1066
+
1067
+ const wantedRouteKey = routeKey(acc, route);
1068
+ let best: { sessionKey: string; route: BncrRoute; updatedAt: number } | null = null;
1069
+
1070
+ if (BNCR_DEBUG_VERBOSE) {
1071
+ this.api.logger.info?.(`[bncr-target-incoming-route] raw=${raw} accountId=${acc} route=${JSON.stringify(route)}`);
1072
+ this.api.logger.info?.(`[bncr-target-incoming-sessionRoutes] raw=${raw} accountId=${acc} sessionRoutes=${JSON.stringify(this.sessionRoutes.entries())}`);
1073
+ }
1074
+
1075
+ for (const [key, info] of this.sessionRoutes.entries()) {
1076
+ if (normalizeAccountId(info.accountId) !== acc) continue;
1077
+ const parsed = parseStrictBncrSessionKey(key);
1078
+ if (!parsed) continue;
1079
+ if (routeKey(acc, parsed.route) !== wantedRouteKey) continue;
1080
+
1081
+ const updatedAt = Number(info.updatedAt || 0);
1082
+ if (!best || updatedAt >= best.updatedAt) {
1083
+ best = {
1084
+ sessionKey: parsed.sessionKey,
1085
+ route: parsed.route,
1086
+ updatedAt,
1087
+ };
1088
+ }
1089
+ }
1090
+
1091
+ // 直接根据raw生成标准sessionkey
1092
+ if (!best) {
1093
+ const updatedAt = 0;
1094
+ best = {
1095
+ sessionKey: `agent:main:bncr:direct:${routeScopeToHex(route)}`,
1096
+ route,
1097
+ updatedAt,
1098
+ };
1099
+ }
1100
+
1101
+ if (BNCR_DEBUG_VERBOSE) {
1102
+ this.api.logger.info?.(`[bncr-target-incoming-best] raw=${raw} accountId=${acc} best=${JSON.stringify(best)}`);
1103
+ }
1104
+
1105
+ if (!best) {
1106
+ this.api.logger.warn?.(`[bncr-target-miss] raw=${raw} accountId=${acc} sessionRoutes=${this.sessionRoutes.size}`);
1107
+ throw new Error(`bncr target not found in known sessions: ${raw}`);
1108
+ }
1109
+
1110
+ // 发送链路命中目标时,同步刷新 lastSession,避免状态页显示过期会话。
1111
+ this.lastSessionByAccount.set(acc, {
1112
+ sessionKey: best.sessionKey,
1113
+ scope: formatDisplayScope(best.route),
1114
+ updatedAt: now(),
1115
+ });
1116
+ this.scheduleSave();
1117
+
1118
+ return {
1119
+ sessionKey: best.sessionKey,
1120
+ route: best.route,
1121
+ displayScope: formatDisplayScope(best.route),
1122
+ };
1123
+ }
1124
+
1125
+ private markActivity(accountId: string, at = now()) {
1126
+ this.lastActivityByAccount.set(normalizeAccountId(accountId), at);
1127
+ }
1128
+
1129
+ private fileAckKey(transferId: string, stage: string, chunkIndex?: number): string {
1130
+ const idx = Number.isFinite(Number(chunkIndex)) ? String(Number(chunkIndex)) : '-';
1131
+ return `${transferId}|${stage}|${idx}`;
1132
+ }
1133
+
1134
+ private waitForFileAck(params: { transferId: string; stage: string; chunkIndex?: number; timeoutMs?: number }) {
1135
+ const transferId = asString(params.transferId).trim();
1136
+ const stage = asString(params.stage).trim();
1137
+ const key = this.fileAckKey(transferId, stage, params.chunkIndex);
1138
+ const timeoutMs = Math.max(1_000, Math.min(Number(params.timeoutMs || FILE_ACK_TIMEOUT_MS), 120_000));
1139
+
1140
+ return new Promise<Record<string, unknown>>((resolve, reject) => {
1141
+ const timer = setTimeout(() => {
1142
+ this.fileAckWaiters.delete(key);
1143
+ reject(new Error(`file ack timeout: ${key}`));
1144
+ }, timeoutMs);
1145
+ this.fileAckWaiters.set(key, { resolve, reject, timer });
1146
+ });
1147
+ }
1148
+
1149
+ private resolveFileAck(params: { transferId: string; stage: string; chunkIndex?: number; payload: Record<string, unknown>; ok: boolean }) {
1150
+ const transferId = asString(params.transferId).trim();
1151
+ const stage = asString(params.stage).trim();
1152
+ const key = this.fileAckKey(transferId, stage, params.chunkIndex);
1153
+ const waiter = this.fileAckWaiters.get(key);
1154
+ if (!waiter) return false;
1155
+ this.fileAckWaiters.delete(key);
1156
+ clearTimeout(waiter.timer);
1157
+ if (params.ok) waiter.resolve(params.payload);
1158
+ else waiter.reject(new Error(asString(params.payload?.errorMessage || params.payload?.error || 'file ack failed')));
1159
+ return true;
1160
+ }
1161
+
1162
+ private pushFileEventToAccount(accountId: string, event: string, payload: Record<string, unknown>) {
1163
+ const connIds = this.resolvePushConnIds(accountId);
1164
+ if (!connIds.size || !this.gatewayContext) {
1165
+ throw new Error(`no active bncr connection for account=${accountId}`);
1166
+ }
1167
+ this.gatewayContext.broadcastToConnIds(event, payload, connIds);
1168
+ }
1169
+
1170
+ private resolveInboundFileType(mimeType: string, fileName: string): string {
1171
+ const mt = asString(mimeType).toLowerCase();
1172
+ const fn = asString(fileName).toLowerCase();
1173
+ if (mt.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(fn)) return 'image';
1174
+ if (mt.startsWith('video/') || /\.(mp4|mov|mkv|avi|webm)$/.test(fn)) return 'video';
1175
+ if (mt.startsWith('audio/') || /\.(mp3|wav|m4a|aac|ogg|flac)$/.test(fn)) return 'audio';
1176
+ return mt || 'file';
1177
+ }
1178
+
1179
+ private resolveInboundFilesDir(): string {
1180
+ const dir = path.join(process.cwd(), '.openclaw', 'media', 'inbound', 'bncr');
1181
+ fs.mkdirSync(dir, { recursive: true });
1182
+ return dir;
1183
+ }
1184
+
1185
+ private async materializeRecvTransfer(st: FileRecvTransferState): Promise<{ path: string; fileSha256: string }> {
1186
+ const dir = this.resolveInboundFilesDir();
1187
+ const safeName = asString(st.fileName).trim() || `${st.transferId}.bin`;
1188
+ const finalPath = path.join(dir, safeName);
1189
+
1190
+ const ordered: Buffer[] = [];
1191
+ for (let i = 0; i < st.totalChunks; i++) {
1192
+ const chunk = st.bufferByChunk.get(i);
1193
+ if (!chunk) throw new Error(`missing chunk ${i}`);
1194
+ ordered.push(chunk);
1195
+ }
1196
+ const merged = Buffer.concat(ordered);
1197
+ if (Number(st.fileSize || 0) > 0 && merged.length !== Number(st.fileSize || 0)) {
1198
+ throw new Error(`size mismatch expected=${st.fileSize} got=${merged.length}`);
1199
+ }
1200
+
1201
+ const sha = createHash('sha256').update(merged).digest('hex');
1202
+ if (st.fileSha256 && sha !== st.fileSha256) {
1203
+ throw new Error(`sha256 mismatch expected=${st.fileSha256} got=${sha}`);
1204
+ }
1205
+
1206
+ fs.writeFileSync(finalPath, merged);
1207
+ return { path: finalPath, fileSha256: sha };
1208
+ }
1209
+
1210
+ private buildStatusMeta(accountId: string) {
1211
+ const acc = normalizeAccountId(accountId);
1212
+ return buildStatusMetaFromRuntime({
1213
+ accountId: acc,
1214
+ connected: this.isOnline(acc),
1215
+ pending: Array.from(this.outbox.values()).filter((v) => v.accountId === acc).length,
1216
+ deadLetter: this.deadLetter.filter((v) => v.accountId === acc).length,
1217
+ activeConnections: this.activeConnectionCount(acc),
1218
+ connectEvents: this.getCounter(this.connectEventsByAccount, acc),
1219
+ inboundEvents: this.getCounter(this.inboundEventsByAccount, acc),
1220
+ activityEvents: this.getCounter(this.activityEventsByAccount, acc),
1221
+ ackEvents: this.getCounter(this.ackEventsByAccount, acc),
1222
+ startedAt: this.startedAt,
1223
+ lastSession: this.lastSessionByAccount.get(acc) || null,
1224
+ lastActivityAt: this.lastActivityByAccount.get(acc) || null,
1225
+ lastInboundAt: this.lastInboundByAccount.get(acc) || null,
1226
+ lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
1227
+ sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc).length,
1228
+ invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
1229
+ legacyAccountResidue: this.countLegacyAccountResidue(acc),
1230
+ channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
1231
+ });
1232
+ }
1233
+
1234
+ getAccountRuntimeSnapshot(accountId: string) {
1235
+ const acc = normalizeAccountId(accountId);
1236
+ return buildAccountRuntimeSnapshot({
1237
+ accountId: acc,
1238
+ connected: this.isOnline(acc),
1239
+ pending: Array.from(this.outbox.values()).filter((v) => v.accountId === acc).length,
1240
+ deadLetter: this.deadLetter.filter((v) => v.accountId === acc).length,
1241
+ activeConnections: this.activeConnectionCount(acc),
1242
+ connectEvents: this.getCounter(this.connectEventsByAccount, acc),
1243
+ inboundEvents: this.getCounter(this.inboundEventsByAccount, acc),
1244
+ activityEvents: this.getCounter(this.activityEventsByAccount, acc),
1245
+ ackEvents: this.getCounter(this.ackEventsByAccount, acc),
1246
+ startedAt: this.startedAt,
1247
+ lastSession: this.lastSessionByAccount.get(acc) || null,
1248
+ lastActivityAt: this.lastActivityByAccount.get(acc) || null,
1249
+ lastInboundAt: this.lastInboundByAccount.get(acc) || null,
1250
+ lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
1251
+ sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc).length,
1252
+ invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
1253
+ legacyAccountResidue: this.countLegacyAccountResidue(acc),
1254
+ running: true,
1255
+ channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
1256
+ });
1257
+ }
1258
+
1259
+ private buildStatusHeadline(accountId: string): string {
1260
+ const acc = normalizeAccountId(accountId);
1261
+ return buildStatusHeadlineFromRuntime({
1262
+ accountId: acc,
1263
+ connected: this.isOnline(acc),
1264
+ pending: Array.from(this.outbox.values()).filter((v) => v.accountId === acc).length,
1265
+ deadLetter: this.deadLetter.filter((v) => v.accountId === acc).length,
1266
+ activeConnections: this.activeConnectionCount(acc),
1267
+ connectEvents: this.getCounter(this.connectEventsByAccount, acc),
1268
+ inboundEvents: this.getCounter(this.inboundEventsByAccount, acc),
1269
+ activityEvents: this.getCounter(this.activityEventsByAccount, acc),
1270
+ ackEvents: this.getCounter(this.ackEventsByAccount, acc),
1271
+ startedAt: this.startedAt,
1272
+ lastSession: this.lastSessionByAccount.get(acc) || null,
1273
+ lastActivityAt: this.lastActivityByAccount.get(acc) || null,
1274
+ lastInboundAt: this.lastInboundByAccount.get(acc) || null,
1275
+ lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
1276
+ sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc).length,
1277
+ invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
1278
+ legacyAccountResidue: this.countLegacyAccountResidue(acc),
1279
+ channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
1280
+ });
1281
+ }
1282
+
1283
+ getStatusHeadline(accountId: string): string {
1284
+ return this.buildStatusHeadline(accountId);
1285
+ }
1286
+
1287
+ getChannelSummary(defaultAccountId: string) {
1288
+ const accountId = normalizeAccountId(defaultAccountId);
1289
+ const runtime = this.getAccountRuntimeSnapshot(accountId);
1290
+ const headline = this.buildStatusHeadline(accountId);
1291
+
1292
+ if (runtime.connected) {
1293
+ return { linked: true, self: { e164: headline } };
1294
+ }
1295
+
1296
+ // 顶层汇总不绑定某个 accountId:任一账号在线都应显示 linked
1297
+ const t = now();
1298
+ for (const c of this.connections.values()) {
1299
+ if (t - c.lastSeenAt <= CONNECT_TTL_MS) {
1300
+ return { linked: true, self: { e164: headline } };
1301
+ }
1302
+ }
1303
+
1304
+ return { linked: false, self: { e164: headline } };
1305
+ }
1306
+
1307
+ private enqueueOutbound(entry: OutboxEntry) {
1308
+ if (BNCR_DEBUG_VERBOSE) {
1309
+ const msg = (entry.payload as any)?.message || {};
1310
+ const type = asString(msg.type || (entry.payload as any)?.type || 'unknown');
1311
+ const text = asString(msg.msg || '');
1312
+ this.api.logger.info?.(
1313
+ `[bncr-outbox-enqueue] ${JSON.stringify({
1314
+ bridge: this.bridgeId,
1315
+ messageId: entry.messageId,
1316
+ accountId: entry.accountId,
1317
+ sessionKey: entry.sessionKey,
1318
+ route: entry.route,
1319
+ type,
1320
+ textLen: text.length,
1321
+ })}`,
1322
+ );
1323
+ }
1324
+ this.outbox.set(entry.messageId, entry);
1325
+ this.scheduleSave();
1326
+ this.wakeAccountWaiters(entry.accountId);
1327
+ this.flushPushQueue(entry.accountId);
1328
+ }
1329
+
1330
+ private moveToDeadLetter(entry: OutboxEntry, reason: string) {
1331
+ const dead: OutboxEntry = {
1332
+ ...entry,
1333
+ lastError: reason,
1334
+ };
1335
+ this.deadLetter.push(dead);
1336
+ if (this.deadLetter.length > 1000) this.deadLetter = this.deadLetter.slice(-1000);
1337
+ this.outbox.delete(entry.messageId);
1338
+ this.scheduleSave();
1339
+ }
1340
+
1341
+ private collectDue(accountId: string, maxBatch: number): Array<Record<string, unknown>> {
1342
+ const due: Array<Record<string, unknown>> = [];
1343
+ const t = now();
1344
+ const key = normalizeAccountId(accountId);
1345
+
1346
+ for (const entry of this.outbox.values()) {
1347
+ if (entry.accountId !== key) continue;
1348
+ if (entry.nextAttemptAt > t) continue;
1349
+
1350
+ const nextAttempt = entry.retryCount + 1;
1351
+ if (nextAttempt > MAX_RETRY) {
1352
+ this.moveToDeadLetter(entry, 'retry-limit');
1353
+ continue;
1354
+ }
1355
+
1356
+ entry.retryCount = nextAttempt;
1357
+ entry.lastAttemptAt = t;
1358
+ entry.nextAttemptAt = t + backoffMs(nextAttempt);
1359
+ this.outbox.set(entry.messageId, entry);
1360
+
1361
+ due.push({
1362
+ ...entry.payload,
1363
+ _meta: {
1364
+ retryCount: entry.retryCount,
1365
+ nextAttemptAt: entry.nextAttemptAt,
1366
+ },
1367
+ });
1368
+
1369
+ if (due.length >= maxBatch) break;
1370
+ }
1371
+
1372
+ if (due.length) this.scheduleSave();
1373
+ return due;
1374
+ }
1375
+
1376
+ private async payloadMediaToBase64(
1377
+ mediaUrl: string,
1378
+ mediaLocalRoots?: readonly string[],
1379
+ ): Promise<{ mediaBase64: string; mimeType?: string; fileName?: string }> {
1380
+ const loaded = await this.api.runtime.media.loadWebMedia(mediaUrl, {
1381
+ localRoots: mediaLocalRoots,
1382
+ maxBytes: 20 * 1024 * 1024,
1383
+ });
1384
+ return {
1385
+ mediaBase64: loaded.buffer.toString('base64'),
1386
+ mimeType: loaded.contentType,
1387
+ fileName: loaded.fileName,
1388
+ };
1389
+ }
1390
+
1391
+ private async sleepMs(ms: number): Promise<void> {
1392
+ await new Promise<void>((resolve) => setTimeout(resolve, Math.max(0, Number(ms || 0))));
1393
+ }
1394
+
1395
+ private waitChunkAck(params: {
1396
+ transferId: string;
1397
+ chunkIndex: number;
1398
+ timeoutMs?: number;
1399
+ }): Promise<void> {
1400
+ const { transferId, chunkIndex } = params;
1401
+ const timeoutMs = Math.max(1_000, Math.min(Number(params.timeoutMs || FILE_TRANSFER_ACK_TTL_MS), 60_000));
1402
+ const started = now();
1403
+
1404
+ return new Promise<void>((resolve, reject) => {
1405
+ const tick = async () => {
1406
+ const st = this.fileSendTransfers.get(transferId);
1407
+ if (!st) {
1408
+ reject(new Error('transfer state missing'));
1409
+ return;
1410
+ }
1411
+ if (st.failedChunks.has(chunkIndex)) {
1412
+ reject(new Error(st.failedChunks.get(chunkIndex) || `chunk ${chunkIndex} failed`));
1413
+ return;
1414
+ }
1415
+ if (st.ackedChunks.has(chunkIndex)) {
1416
+ resolve();
1417
+ return;
1418
+ }
1419
+ if (now() - started >= timeoutMs) {
1420
+ reject(new Error(`chunk ack timeout index=${chunkIndex}`));
1421
+ return;
1422
+ }
1423
+ await this.sleepMs(120);
1424
+ void tick();
1425
+ };
1426
+ void tick();
1427
+ });
1428
+ }
1429
+
1430
+ private waitCompleteAck(params: {
1431
+ transferId: string;
1432
+ timeoutMs?: number;
1433
+ }): Promise<{ path: string }> {
1434
+ const { transferId } = params;
1435
+ const timeoutMs = Math.max(2_000, Math.min(Number(params.timeoutMs || 60_000), 120_000));
1436
+ const started = now();
1437
+
1438
+ return new Promise<{ path: string }>((resolve, reject) => {
1439
+ const tick = async () => {
1440
+ const st = this.fileSendTransfers.get(transferId);
1441
+ if (!st) {
1442
+ reject(new Error('transfer state missing'));
1443
+ return;
1444
+ }
1445
+ if (st.status === 'aborted') {
1446
+ reject(new Error(st.error || 'transfer aborted'));
1447
+ return;
1448
+ }
1449
+ if (st.status === 'completed' && st.completedPath) {
1450
+ resolve({ path: st.completedPath });
1451
+ return;
1452
+ }
1453
+ if (now() - started >= timeoutMs) {
1454
+ reject(new Error('complete ack timeout'));
1455
+ return;
1456
+ }
1457
+ await this.sleepMs(150);
1458
+ void tick();
1459
+ };
1460
+ void tick();
1461
+ });
1462
+ }
1463
+
1464
+ private async transferMediaToBncrClient(params: {
1465
+ accountId: string;
1466
+ sessionKey: string;
1467
+ route: BncrRoute;
1468
+ mediaUrl: string;
1469
+ mediaLocalRoots?: readonly string[];
1470
+ }): Promise<{ mode: 'base64' | 'chunk'; mimeType?: string; fileName?: string; mediaBase64?: string; path?: string }> {
1471
+ const loaded = await this.api.runtime.media.loadWebMedia(params.mediaUrl, {
1472
+ localRoots: params.mediaLocalRoots,
1473
+ maxBytes: 50 * 1024 * 1024,
1474
+ });
1475
+
1476
+ const size = loaded.buffer.byteLength;
1477
+ const mimeType = loaded.contentType;
1478
+ const fileName = resolveOutboundFileName({
1479
+ mediaUrl: params.mediaUrl,
1480
+ fileName: loaded.fileName,
1481
+ mimeType,
1482
+ });
1483
+
1484
+ if (!FILE_FORCE_CHUNK && size <= FILE_INLINE_THRESHOLD) {
1485
+ return {
1486
+ mode: 'base64',
1487
+ mimeType,
1488
+ fileName,
1489
+ mediaBase64: loaded.buffer.toString('base64'),
1490
+ };
1491
+ }
1492
+
1493
+ const ctx = this.gatewayContext;
1494
+ if (!ctx) throw new Error('gateway context unavailable');
1495
+
1496
+ const connIds = this.resolvePushConnIds(params.accountId);
1497
+ if (!connIds.size) throw new Error('no active bncr client for file chunk transfer');
1498
+
1499
+ const transferId = randomUUID();
1500
+ const chunkSize = 256 * 1024;
1501
+ const totalChunks = Math.ceil(size / chunkSize);
1502
+ const fileSha256 = createHash('sha256').update(loaded.buffer).digest('hex');
1503
+
1504
+ const st: FileSendTransferState = {
1505
+ transferId,
1506
+ accountId: normalizeAccountId(params.accountId),
1507
+ sessionKey: params.sessionKey,
1508
+ route: params.route,
1509
+ fileName,
1510
+ mimeType: mimeType || 'application/octet-stream',
1511
+ fileSize: size,
1512
+ chunkSize,
1513
+ totalChunks,
1514
+ fileSha256,
1515
+ startedAt: now(),
1516
+ status: 'init',
1517
+ ackedChunks: new Set(),
1518
+ failedChunks: new Map(),
1519
+ };
1520
+ this.fileSendTransfers.set(transferId, st);
1521
+
1522
+ ctx.broadcastToConnIds('bncr.file.init', {
1523
+ transferId,
1524
+ direction: 'oc2bncr',
1525
+ sessionKey: params.sessionKey,
1526
+ platform: params.route.platform,
1527
+ groupId: params.route.groupId,
1528
+ userId: params.route.userId,
1529
+ fileName,
1530
+ mimeType,
1531
+ fileSize: size,
1532
+ chunkSize,
1533
+ totalChunks,
1534
+ fileSha256,
1535
+ ts: now(),
1536
+ }, connIds);
1537
+
1538
+ // 逐块发送并等待 ACK
1539
+ for (let idx = 0; idx < totalChunks; idx++) {
1540
+ const start = idx * chunkSize;
1541
+ const end = Math.min(start + chunkSize, size);
1542
+ const slice = loaded.buffer.subarray(start, end);
1543
+ const chunkSha256 = createHash('sha256').update(slice).digest('hex');
1544
+
1545
+ let ok = false;
1546
+ let lastErr: unknown = null;
1547
+ for (let attempt = 1; attempt <= 3; attempt++) {
1548
+ ctx.broadcastToConnIds('bncr.file.chunk', {
1549
+ transferId,
1550
+ chunkIndex: idx,
1551
+ offset: start,
1552
+ size: slice.byteLength,
1553
+ chunkSha256,
1554
+ base64: slice.toString('base64'),
1555
+ ts: now(),
1556
+ }, connIds);
1557
+
1558
+ try {
1559
+ await this.waitChunkAck({ transferId, chunkIndex: idx, timeoutMs: FILE_TRANSFER_ACK_TTL_MS });
1560
+ ok = true;
1561
+ break;
1562
+ } catch (err) {
1563
+ lastErr = err;
1564
+ await this.sleepMs(150 * attempt);
1565
+ }
1566
+ }
1567
+
1568
+ if (!ok) {
1569
+ st.status = 'aborted';
1570
+ st.error = String((lastErr as any)?.message || lastErr || `chunk-${idx}-failed`);
1571
+ this.fileSendTransfers.set(transferId, st);
1572
+ ctx.broadcastToConnIds('bncr.file.abort', {
1573
+ transferId,
1574
+ reason: st.error,
1575
+ ts: now(),
1576
+ }, connIds);
1577
+ throw new Error(st.error);
1578
+ }
1579
+ }
1580
+
1581
+ ctx.broadcastToConnIds('bncr.file.complete', {
1582
+ transferId,
1583
+ ts: now(),
1584
+ }, connIds);
1585
+
1586
+ const done = await this.waitCompleteAck({ transferId, timeoutMs: 60_000 });
1587
+
1588
+ return {
1589
+ mode: 'chunk',
1590
+ mimeType,
1591
+ fileName,
1592
+ path: done.path,
1593
+ };
1594
+ }
1595
+
1596
+ private async enqueueFromReply(params: {
1597
+ accountId: string;
1598
+ sessionKey: string;
1599
+ route: BncrRoute;
1600
+ payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] };
1601
+ mediaLocalRoots?: readonly string[];
1602
+ }) {
1603
+ const { accountId, sessionKey, route, payload, mediaLocalRoots } = params;
1604
+
1605
+ const mediaList = payload.mediaUrls?.length
1606
+ ? payload.mediaUrls
1607
+ : payload.mediaUrl
1608
+ ? [payload.mediaUrl]
1609
+ : [];
1610
+
1611
+ if (mediaList.length > 0) {
1612
+ let first = true;
1613
+ for (const mediaUrl of mediaList) {
1614
+ const media = await this.transferMediaToBncrClient({
1615
+ accountId,
1616
+ sessionKey,
1617
+ route,
1618
+ mediaUrl,
1619
+ mediaLocalRoots,
1620
+ });
1621
+ const messageId = randomUUID();
1622
+ const mediaMsg = first ? asString(payload.text || '') : '';
1623
+ const frame = buildBncrMediaOutboundFrame({
1624
+ messageId,
1625
+ sessionKey,
1626
+ route,
1627
+ media,
1628
+ mediaUrl,
1629
+ mediaMsg,
1630
+ fileName: resolveOutboundFileName({
1631
+ mediaUrl,
1632
+ fileName: media.fileName,
1633
+ mimeType: media.mimeType,
1634
+ }),
1635
+ now: now(),
1636
+ });
1637
+
1638
+ this.enqueueOutbound({
1639
+ messageId,
1640
+ accountId: normalizeAccountId(accountId),
1641
+ sessionKey,
1642
+ route,
1643
+ payload: frame,
1644
+ createdAt: now(),
1645
+ retryCount: 0,
1646
+ nextAttemptAt: now(),
1647
+ });
1648
+ first = false;
1649
+ }
1650
+ return;
1651
+ }
1652
+
1653
+ const text = asString(payload.text || '').trim();
1654
+ if (!text) return;
1655
+
1656
+ const messageId = randomUUID();
1657
+ const frame = {
1658
+ type: 'message.outbound',
1659
+ messageId,
1660
+ idempotencyKey: messageId,
1661
+ sessionKey,
1662
+ message: {
1663
+ platform: route.platform,
1664
+ groupId: route.groupId,
1665
+ userId: route.userId,
1666
+ type: 'text',
1667
+ msg: text,
1668
+ path: '',
1669
+ base64: '',
1670
+ fileName: '',
1671
+ },
1672
+ ts: now(),
1673
+ };
1674
+
1675
+ this.enqueueOutbound({
1676
+ messageId,
1677
+ accountId: normalizeAccountId(accountId),
1678
+ sessionKey,
1679
+ route,
1680
+ payload: frame,
1681
+ createdAt: now(),
1682
+ retryCount: 0,
1683
+ nextAttemptAt: now(),
1684
+ });
1685
+ }
1686
+
1687
+ handleConnect = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
1688
+ const accountId = normalizeAccountId(asString(params?.accountId || ''));
1689
+ const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
1690
+ const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1691
+
1692
+ if (BNCR_DEBUG_VERBOSE) {
1693
+ this.api.logger.info?.(
1694
+ `[bncr-connect] ${JSON.stringify({
1695
+ bridge: this.bridgeId,
1696
+ accountId,
1697
+ connId,
1698
+ clientId,
1699
+ hasContext: Boolean(context),
1700
+ })}`,
1701
+ );
1702
+ }
1703
+
1704
+ this.rememberGatewayContext(context);
1705
+ this.markSeen(accountId, connId, clientId);
1706
+ this.markActivity(accountId);
1707
+ this.incrementCounter(this.connectEventsByAccount, accountId);
1708
+
1709
+ respond(true, {
1710
+ channel: CHANNEL_ID,
1711
+ accountId,
1712
+ bridgeVersion: BRIDGE_VERSION,
1713
+ pushEvent: BNCR_PUSH_EVENT,
1714
+ online: true,
1715
+ isPrimary: this.isPrimaryConnection(accountId, clientId),
1716
+ activeConnections: this.activeConnectionCount(accountId),
1717
+ pending: Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length,
1718
+ deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
1719
+ diagnostics: this.buildIntegratedDiagnostics(accountId),
1720
+ now: now(),
1721
+ });
1722
+
1723
+ // WS 一旦在线,立即尝试把离线期间积压队列直推出去
1724
+ this.flushPushQueue(accountId);
1725
+ };
1726
+
1727
+ handleAck = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
1728
+ const accountId = normalizeAccountId(asString(params?.accountId || ''));
1729
+ const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
1730
+ const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1731
+ this.rememberGatewayContext(context);
1732
+ this.markSeen(accountId, connId, clientId);
1733
+ this.incrementCounter(this.ackEventsByAccount, accountId);
1734
+
1735
+ const messageId = asString(params?.messageId || '').trim();
1736
+ if (BNCR_DEBUG_VERBOSE) {
1737
+ this.api.logger.info?.(
1738
+ `[bncr-outbox-ack] ${JSON.stringify({
1739
+ accountId,
1740
+ messageId,
1741
+ ok: params?.ok !== false,
1742
+ fatal: params?.fatal === true,
1743
+ error: asString(params?.error || ''),
1744
+ })}`,
1745
+ );
1746
+ }
1747
+ if (!messageId) {
1748
+ respond(false, { error: 'messageId required' });
1749
+ return;
1750
+ }
1751
+
1752
+ const entry = this.outbox.get(messageId);
1753
+ if (!entry) {
1754
+ respond(true, { ok: true, message: 'already-acked-or-missing' });
1755
+ return;
1756
+ }
1757
+
1758
+ if (entry.accountId !== accountId) {
1759
+ respond(false, { error: 'account mismatch' });
1760
+ return;
1761
+ }
1762
+
1763
+ const ok = params?.ok !== false;
1764
+ const fatal = params?.fatal === true;
1765
+
1766
+ if (ok) {
1767
+ this.outbox.delete(messageId);
1768
+ this.scheduleSave();
1769
+ respond(true, { ok: true });
1770
+ return;
1771
+ }
1772
+
1773
+ if (fatal) {
1774
+ this.moveToDeadLetter(entry, asString(params?.error || 'fatal-ack'));
1775
+ respond(true, { ok: true, movedToDeadLetter: true });
1776
+ return;
1777
+ }
1778
+
1779
+ entry.nextAttemptAt = now() + 1_000;
1780
+ entry.lastError = asString(params?.error || 'retryable-ack');
1781
+ this.outbox.set(messageId, entry);
1782
+ this.scheduleSave();
1783
+
1784
+ respond(true, { ok: true, willRetry: true });
1785
+ };
1786
+
1787
+ handleActivity = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
1788
+ this.syncDebugFlag();
1789
+ const accountId = normalizeAccountId(asString(params?.accountId || ''));
1790
+ const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
1791
+ const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1792
+ if (BNCR_DEBUG_VERBOSE) {
1793
+ this.api.logger.info?.(
1794
+ `[bncr-activity] ${JSON.stringify({
1795
+ bridge: this.bridgeId,
1796
+ accountId,
1797
+ connId,
1798
+ clientId,
1799
+ hasContext: Boolean(context),
1800
+ })}`,
1801
+ );
1802
+ }
1803
+ this.rememberGatewayContext(context);
1804
+ this.markSeen(accountId, connId, clientId);
1805
+ this.markActivity(accountId);
1806
+ this.incrementCounter(this.activityEventsByAccount, accountId);
1807
+
1808
+ // 轻量活动心跳:仅刷新在线活跃状态,不承担拉取职责。
1809
+ respond(true, {
1810
+ accountId,
1811
+ ok: true,
1812
+ event: 'activity',
1813
+ activeConnections: this.activeConnectionCount(accountId),
1814
+ pending: Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length,
1815
+ deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
1816
+ now: now(),
1817
+ });
1818
+ };
1819
+
1820
+ handleDiagnostics = async ({ params, respond }: GatewayRequestHandlerOptions) => {
1821
+ const accountId = normalizeAccountId(asString(params?.accountId || ''));
1822
+ const cfg = await this.api.runtime.config.loadConfig();
1823
+ const runtime = this.getAccountRuntimeSnapshot(accountId);
1824
+ const diagnostics = this.buildIntegratedDiagnostics(accountId);
1825
+ const permissions = buildBncrPermissionSummary(cfg ?? {});
1826
+ const probe = probeBncrAccount({
1827
+ accountId,
1828
+ connected: Boolean(runtime?.connected),
1829
+ pending: Number(runtime?.meta?.pending ?? 0),
1830
+ deadLetter: Number(runtime?.meta?.deadLetter ?? 0),
1831
+ activeConnections: this.activeConnectionCount(accountId),
1832
+ invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(accountId),
1833
+ legacyAccountResidue: this.countLegacyAccountResidue(accountId),
1834
+ lastActivityAt: runtime?.meta?.lastActivityAt ?? null,
1835
+ structure: {
1836
+ coreComplete: true,
1837
+ inboundComplete: true,
1838
+ outboundComplete: true,
1839
+ },
1840
+ });
1841
+
1842
+ respond(true, {
1843
+ channel: CHANNEL_ID,
1844
+ accountId,
1845
+ runtime,
1846
+ diagnostics,
1847
+ permissions,
1848
+ probe,
1849
+ now: now(),
1850
+ });
1851
+ };
1852
+
1853
+ handleFileInit = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
1854
+ const accountId = normalizeAccountId(asString(params?.accountId || ''));
1855
+ const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
1856
+ const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1857
+ this.rememberGatewayContext(context);
1858
+ this.markSeen(accountId, connId, clientId);
1859
+ this.markActivity(accountId);
1860
+
1861
+ const transferId = asString(params?.transferId || '').trim();
1862
+ const sessionKey = asString(params?.sessionKey || '').trim();
1863
+ const fileName = asString(params?.fileName || '').trim() || 'file.bin';
1864
+ const mimeType = asString(params?.mimeType || '').trim() || 'application/octet-stream';
1865
+ const fileSize = Number(params?.fileSize || 0);
1866
+ const chunkSize = Number(params?.chunkSize || 256 * 1024);
1867
+ const totalChunks = Number(params?.totalChunks || 0);
1868
+ const fileSha256 = asString(params?.fileSha256 || '').trim();
1869
+
1870
+ if (!transferId || !sessionKey || !fileSize || !chunkSize || !totalChunks) {
1871
+ respond(false, { error: 'transferId/sessionKey/fileSize/chunkSize/totalChunks required' });
1872
+ return;
1873
+ }
1874
+
1875
+ const normalized = normalizeStoredSessionKey(sessionKey);
1876
+ if (!normalized) {
1877
+ respond(false, { error: 'invalid sessionKey' });
1878
+ return;
1879
+ }
1880
+
1881
+ const existing = this.fileRecvTransfers.get(transferId);
1882
+ if (existing) {
1883
+ respond(true, {
1884
+ ok: true,
1885
+ transferId,
1886
+ status: existing.status,
1887
+ duplicated: true,
1888
+ });
1889
+ return;
1890
+ }
1891
+
1892
+ const route = parseRouteLike({
1893
+ platform: asString(params?.platform || normalized.route.platform),
1894
+ groupId: asString(params?.groupId || normalized.route.groupId),
1895
+ userId: asString(params?.userId || normalized.route.userId),
1896
+ }) || normalized.route;
1897
+
1898
+ this.fileRecvTransfers.set(transferId, {
1899
+ transferId,
1900
+ accountId,
1901
+ sessionKey: normalized.sessionKey,
1902
+ route,
1903
+ fileName,
1904
+ mimeType,
1905
+ fileSize,
1906
+ chunkSize,
1907
+ totalChunks,
1908
+ fileSha256,
1909
+ startedAt: now(),
1910
+ status: 'init',
1911
+ bufferByChunk: new Map(),
1912
+ receivedChunks: new Set(),
1913
+ });
1914
+
1915
+ respond(true, {
1916
+ ok: true,
1917
+ transferId,
1918
+ status: 'init',
1919
+ });
1920
+ };
1921
+
1922
+ handleFileChunk = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
1923
+ const accountId = normalizeAccountId(asString(params?.accountId || ''));
1924
+ const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
1925
+ const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1926
+ this.rememberGatewayContext(context);
1927
+ this.markSeen(accountId, connId, clientId);
1928
+ this.markActivity(accountId);
1929
+
1930
+ const transferId = asString(params?.transferId || '').trim();
1931
+ const chunkIndex = Number(params?.chunkIndex ?? -1);
1932
+ const offset = Number(params?.offset ?? 0);
1933
+ const size = Number(params?.size ?? 0);
1934
+ const chunkSha256 = asString(params?.chunkSha256 || '').trim();
1935
+ const base64 = asString(params?.base64 || '');
1936
+
1937
+ if (!transferId || chunkIndex < 0 || !base64) {
1938
+ respond(false, { error: 'transferId/chunkIndex/base64 required' });
1939
+ return;
1940
+ }
1941
+
1942
+ const st = this.fileRecvTransfers.get(transferId);
1943
+ if (!st) {
1944
+ respond(false, { error: 'transfer not found' });
1945
+ return;
1946
+ }
1947
+
1948
+ try {
1949
+ const buf = Buffer.from(base64, 'base64');
1950
+ if (size > 0 && buf.length !== size) {
1951
+ throw new Error(`chunk size mismatch expected=${size} got=${buf.length}`);
1952
+ }
1953
+ if (chunkSha256) {
1954
+ const digest = createHash('sha256').update(buf).digest('hex');
1955
+ if (digest !== chunkSha256) throw new Error('chunk sha256 mismatch');
1956
+ }
1957
+ st.bufferByChunk.set(chunkIndex, buf);
1958
+ st.receivedChunks.add(chunkIndex);
1959
+ st.status = 'transferring';
1960
+ this.fileRecvTransfers.set(transferId, st);
1961
+
1962
+ respond(true, {
1963
+ ok: true,
1964
+ transferId,
1965
+ chunkIndex,
1966
+ offset,
1967
+ received: st.receivedChunks.size,
1968
+ totalChunks: st.totalChunks,
1969
+ });
1970
+ } catch (error) {
1971
+ respond(false, { error: String((error as any)?.message || error || 'chunk invalid') });
1972
+ }
1973
+ };
1974
+
1975
+ handleFileComplete = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
1976
+ const accountId = normalizeAccountId(asString(params?.accountId || ''));
1977
+ const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
1978
+ const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1979
+ this.rememberGatewayContext(context);
1980
+ this.markSeen(accountId, connId, clientId);
1981
+ this.markActivity(accountId);
1982
+
1983
+ const transferId = asString(params?.transferId || '').trim();
1984
+ if (!transferId) {
1985
+ respond(false, { error: 'transferId required' });
1986
+ return;
1987
+ }
1988
+
1989
+ const st = this.fileRecvTransfers.get(transferId);
1990
+ if (!st) {
1991
+ respond(false, { error: 'transfer not found' });
1992
+ return;
1993
+ }
1994
+
1995
+ try {
1996
+ if (st.receivedChunks.size < st.totalChunks) {
1997
+ throw new Error(`chunk not complete received=${st.receivedChunks.size} total=${st.totalChunks}`);
1998
+ }
1999
+
2000
+ const ordered = Array.from(st.bufferByChunk.entries()).sort((a, b) => a[0] - b[0]).map((x) => x[1]);
2001
+ const merged = Buffer.concat(ordered);
2002
+ if (st.fileSize > 0 && merged.length !== st.fileSize) {
2003
+ throw new Error(`file size mismatch expected=${st.fileSize} got=${merged.length}`);
2004
+ }
2005
+ const digest = createHash('sha256').update(merged).digest('hex');
2006
+ if (st.fileSha256 && digest !== st.fileSha256) {
2007
+ throw new Error('file sha256 mismatch');
2008
+ }
2009
+
2010
+ const saved = await this.api.runtime.channel.media.saveMediaBuffer(
2011
+ merged,
2012
+ st.mimeType,
2013
+ 'inbound',
2014
+ 50 * 1024 * 1024,
2015
+ st.fileName,
2016
+ );
2017
+ st.completedPath = saved.path;
2018
+ st.status = 'completed';
2019
+ this.fileRecvTransfers.set(transferId, st);
2020
+
2021
+ respond(true, {
2022
+ ok: true,
2023
+ transferId,
2024
+ path: saved.path,
2025
+ size: merged.length,
2026
+ fileName: st.fileName,
2027
+ mimeType: st.mimeType,
2028
+ fileSha256: digest,
2029
+ });
2030
+ } catch (error) {
2031
+ st.status = 'aborted';
2032
+ st.error = String((error as any)?.message || error || 'complete failed');
2033
+ this.fileRecvTransfers.set(transferId, st);
2034
+ respond(false, { error: st.error });
2035
+ }
2036
+ };
2037
+
2038
+ handleFileAbort = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
2039
+ const accountId = normalizeAccountId(asString(params?.accountId || ''));
2040
+ const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2041
+ const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2042
+ this.rememberGatewayContext(context);
2043
+ this.markSeen(accountId, connId, clientId);
2044
+ this.markActivity(accountId);
2045
+
2046
+ const transferId = asString(params?.transferId || '').trim();
2047
+ if (!transferId) {
2048
+ respond(false, { error: 'transferId required' });
2049
+ return;
2050
+ }
2051
+
2052
+ const st = this.fileRecvTransfers.get(transferId);
2053
+ if (!st) {
2054
+ respond(true, { ok: true, transferId, message: 'not-found' });
2055
+ return;
2056
+ }
2057
+
2058
+ st.status = 'aborted';
2059
+ st.error = asString(params?.reason || 'aborted');
2060
+ this.fileRecvTransfers.set(transferId, st);
2061
+
2062
+ respond(true, {
2063
+ ok: true,
2064
+ transferId,
2065
+ status: 'aborted',
2066
+ });
2067
+ };
2068
+
2069
+ handleFileAck = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
2070
+ const accountId = normalizeAccountId(asString(params?.accountId || ''));
2071
+ const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2072
+ const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2073
+ this.rememberGatewayContext(context);
2074
+ this.markSeen(accountId, connId, clientId);
2075
+ this.markActivity(accountId);
2076
+
2077
+ const transferId = asString(params?.transferId || '').trim();
2078
+ const stage = asString(params?.stage || '').trim();
2079
+ const ok = params?.ok !== false;
2080
+ const chunkIndex = Number(params?.chunkIndex ?? -1);
2081
+
2082
+ if (!transferId || !stage) {
2083
+ respond(false, { error: 'transferId/stage required' });
2084
+ return;
2085
+ }
2086
+
2087
+ const st = this.fileSendTransfers.get(transferId);
2088
+ if (st) {
2089
+ if (!ok) {
2090
+ const code = asString(params?.errorCode || 'ACK_FAILED');
2091
+ const msg = asString(params?.errorMessage || 'ack failed');
2092
+ st.error = `${code}:${msg}`;
2093
+ if (stage === 'chunk' && chunkIndex >= 0) st.failedChunks.set(chunkIndex, st.error);
2094
+ if (stage === 'complete') st.status = 'aborted';
2095
+ } else {
2096
+ if (stage === 'chunk' && chunkIndex >= 0) {
2097
+ st.ackedChunks.add(chunkIndex);
2098
+ st.status = 'transferring';
2099
+ }
2100
+ if (stage === 'complete') {
2101
+ st.status = 'completed';
2102
+ st.completedPath = asString(params?.path || '').trim() || st.completedPath;
2103
+ }
2104
+ }
2105
+ this.fileSendTransfers.set(transferId, st);
2106
+ }
2107
+
2108
+ // 唤醒等待中的 chunk/complete ACK
2109
+ this.resolveFileAck({
2110
+ transferId,
2111
+ stage,
2112
+ chunkIndex: chunkIndex >= 0 ? chunkIndex : undefined,
2113
+ payload: {
2114
+ ok,
2115
+ transferId,
2116
+ stage,
2117
+ path: asString(params?.path || '').trim(),
2118
+ errorCode: asString(params?.errorCode || ''),
2119
+ errorMessage: asString(params?.errorMessage || ''),
2120
+ },
2121
+ ok,
2122
+ });
2123
+
2124
+ respond(true, {
2125
+ ok: true,
2126
+ transferId,
2127
+ stage,
2128
+ state: st?.status || 'late',
2129
+ });
2130
+ };
2131
+
2132
+ handleInbound = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
2133
+ const parsed = parseBncrInboundParams(params);
2134
+ const { accountId, platform, groupId, userId, sessionKeyfromroute, route, text, msgType, mediaBase64, mediaPathFromTransfer, mimeType, fileName, msgId, dedupKey, peer, extracted } = parsed;
2135
+ const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2136
+ const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2137
+ this.rememberGatewayContext(context);
2138
+ this.markSeen(accountId, connId, clientId);
2139
+ this.markActivity(accountId);
2140
+ this.incrementCounter(this.inboundEventsByAccount, accountId);
2141
+
2142
+ if (!platform || (!userId && !groupId)) {
2143
+ respond(false, { error: 'platform/groupId/userId required' });
2144
+ return;
2145
+ }
2146
+ if (this.markInboundDedupSeen(dedupKey)) {
2147
+ respond(true, {
2148
+ accepted: true,
2149
+ duplicated: true,
2150
+ accountId,
2151
+ msgId: msgId ?? null,
2152
+ });
2153
+ return;
2154
+ }
2155
+
2156
+ const cfg = await this.api.runtime.config.loadConfig();
2157
+ const gate = checkBncrMessageGate({
2158
+ parsed,
2159
+ cfg,
2160
+ account: resolveAccount(cfg, accountId),
2161
+ });
2162
+ if (!gate.allowed) {
2163
+ respond(true, {
2164
+ accepted: false,
2165
+ accountId,
2166
+ msgId: msgId ?? null,
2167
+ reason: gate.reason,
2168
+ });
2169
+ return;
2170
+ }
2171
+
2172
+ const baseSessionKey = normalizeInboundSessionKey(sessionKeyfromroute, route)
2173
+ || this.api.runtime.channel.routing.resolveAgentRoute({
2174
+ cfg,
2175
+ channel: CHANNEL_ID,
2176
+ accountId,
2177
+ peer,
2178
+ }).sessionKey;
2179
+ const taskSessionKey = withTaskSessionKey(baseSessionKey, extracted.taskKey);
2180
+ const sessionKey = taskSessionKey || baseSessionKey;
2181
+
2182
+ respond(true, {
2183
+ accepted: true,
2184
+ accountId,
2185
+ sessionKey,
2186
+ msgId: msgId ?? null,
2187
+ taskKey: extracted.taskKey ?? null,
2188
+ });
2189
+
2190
+ void dispatchBncrInbound({
2191
+ api: this.api,
2192
+ channelId: CHANNEL_ID,
2193
+ cfg,
2194
+ parsed,
2195
+ rememberSessionRoute: (sessionKey, accountId, route) => this.rememberSessionRoute(sessionKey, accountId, route),
2196
+ enqueueFromReply: (args) => this.enqueueFromReply(args),
2197
+ setInboundActivity: (accountId, at) => {
2198
+ this.lastInboundByAccount.set(accountId, at);
2199
+ this.markActivity(accountId, at);
2200
+ },
2201
+ scheduleSave: () => this.scheduleSave(),
2202
+ logger: this.api.logger,
2203
+ }).catch((err) => {
2204
+ this.api.logger.error?.(`bncr inbound process failed: ${String(err)}`);
2205
+ });
2206
+ };
2207
+
2208
+ channelStartAccount = async (ctx: any) => {
2209
+ const accountId = normalizeAccountId(ctx.accountId);
2210
+
2211
+ const tick = () => {
2212
+ const connected = this.isOnline(accountId);
2213
+ const previous = ctx.getStatus?.() || {};
2214
+ const lastActAt = this.lastActivityByAccount.get(accountId) || previous?.lastEventAt || null;
2215
+
2216
+ ctx.setStatus?.({
2217
+ ...previous,
2218
+ accountId,
2219
+ running: true,
2220
+ connected,
2221
+ lastEventAt: lastActAt,
2222
+ // 状态映射:在线=linked,离线=configured
2223
+ mode: connected ? 'linked' : 'configured',
2224
+ lastError: previous?.lastError ?? null,
2225
+ meta: this.buildStatusMeta(accountId),
2226
+ });
2227
+ };
2228
+
2229
+ tick();
2230
+ const timer = setInterval(tick, 5_000);
2231
+
2232
+ await new Promise<void>((resolve) => {
2233
+ const onAbort = () => {
2234
+ clearInterval(timer);
2235
+ resolve();
2236
+ };
2237
+
2238
+ if (ctx.abortSignal?.aborted) {
2239
+ onAbort();
2240
+ return;
2241
+ }
2242
+
2243
+ ctx.abortSignal?.addEventListener?.('abort', onAbort, { once: true });
2244
+ });
2245
+ };
2246
+
2247
+ channelStopAccount = async (_ctx: any) => {
2248
+ // no-op
2249
+ };
2250
+
2251
+ channelSendText = async (ctx: any) => {
2252
+ const accountId = normalizeAccountId(ctx.accountId);
2253
+ const to = asString(ctx.to || '').trim();
2254
+
2255
+ if (BNCR_DEBUG_VERBOSE) {
2256
+ this.api.logger.info?.(
2257
+ `[bncr-send-entry:text] ${JSON.stringify({
2258
+ accountId,
2259
+ to,
2260
+ text: asString(ctx?.text || ''),
2261
+ mediaUrl: asString(ctx?.mediaUrl || ''),
2262
+ sessionKey: asString(ctx?.sessionKey || ''),
2263
+ mirrorSessionKey: asString(ctx?.mirror?.sessionKey || ''),
2264
+ rawCtx: {
2265
+ to: ctx?.to,
2266
+ accountId: ctx?.accountId,
2267
+ threadId: ctx?.threadId,
2268
+ replyToId: ctx?.replyToId,
2269
+ },
2270
+ })}`,
2271
+ );
2272
+ }
2273
+
2274
+ return sendBncrText({
2275
+ channelId: CHANNEL_ID,
2276
+ accountId,
2277
+ to,
2278
+ text: asString(ctx.text || ''),
2279
+ mediaLocalRoots: ctx.mediaLocalRoots,
2280
+ resolveVerifiedTarget: (to, accountId) => this.resolveVerifiedTarget(to, accountId),
2281
+ rememberSessionRoute: (sessionKey, accountId, route) => this.rememberSessionRoute(sessionKey, accountId, route),
2282
+ enqueueFromReply: (args) => this.enqueueFromReply(args),
2283
+ createMessageId: () => randomUUID(),
2284
+ });
2285
+ };
2286
+
2287
+ channelSendMedia = async (ctx: any) => {
2288
+ const accountId = normalizeAccountId(ctx.accountId);
2289
+ const to = asString(ctx.to || '').trim();
2290
+
2291
+ if (BNCR_DEBUG_VERBOSE) {
2292
+ this.api.logger.info?.(
2293
+ `[bncr-send-entry:media] ${JSON.stringify({
2294
+ accountId,
2295
+ to,
2296
+ text: asString(ctx?.text || ''),
2297
+ mediaUrl: asString(ctx?.mediaUrl || ''),
2298
+ mediaUrls: Array.isArray(ctx?.mediaUrls) ? ctx.mediaUrls : undefined,
2299
+ sessionKey: asString(ctx?.sessionKey || ''),
2300
+ mirrorSessionKey: asString(ctx?.mirror?.sessionKey || ''),
2301
+ rawCtx: {
2302
+ to: ctx?.to,
2303
+ accountId: ctx?.accountId,
2304
+ threadId: ctx?.threadId,
2305
+ replyToId: ctx?.replyToId,
2306
+ },
2307
+ })}`,
2308
+ );
2309
+ }
2310
+
2311
+ return sendBncrMedia({
2312
+ channelId: CHANNEL_ID,
2313
+ accountId,
2314
+ to,
2315
+ text: asString(ctx.text || ''),
2316
+ mediaUrl: asString(ctx.mediaUrl || ''),
2317
+ mediaLocalRoots: ctx.mediaLocalRoots,
2318
+ resolveVerifiedTarget: (to, accountId) => this.resolveVerifiedTarget(to, accountId),
2319
+ rememberSessionRoute: (sessionKey, accountId, route) => this.rememberSessionRoute(sessionKey, accountId, route),
2320
+ enqueueFromReply: (args) => this.enqueueFromReply(args),
2321
+ createMessageId: () => randomUUID(),
2322
+ });
2323
+ };
2324
+ }
2325
+
2326
+ export function createBncrBridge(api: OpenClawPluginApi) {
2327
+ return new BncrBridgeRuntime(api);
2328
+ }
2329
+
2330
+ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2331
+ const plugin = {
2332
+ id: CHANNEL_ID,
2333
+ meta: {
2334
+ id: CHANNEL_ID,
2335
+ label: 'Bncr',
2336
+ selectionLabel: 'Bncr Client',
2337
+ docsPath: '/channels/bncr',
2338
+ blurb: 'Bncr Channel.',
2339
+ aliases: ['bncr'],
2340
+ },
2341
+ capabilities: {
2342
+ chatTypes: ['direct'] as ChatType[],
2343
+ media: true,
2344
+ reply: true,
2345
+ nativeCommands: true,
2346
+ },
2347
+ messaging: {
2348
+ // 接收任意标签输入;不在 normalize 阶段做格式门槛,统一下沉到发送前验证。
2349
+ normalizeTarget: (raw: string) => {
2350
+ const input = asString(raw).trim();
2351
+ return input || undefined;
2352
+ },
2353
+ targetResolver: {
2354
+ looksLikeId: (raw: string, normalized?: string) => {
2355
+ return Boolean(asString(normalized || raw).trim());
2356
+ },
2357
+ hint: 'Standard to=Bncr:<platform>:<group>:<user> or Bncr:<platform>:<user>; sessionKey keeps existing strict/legacy compatibility, canonical sessionKey=agent:main:bncr:direct:<hex>',
2358
+ },
2359
+ },
2360
+ configSchema: BncrConfigSchema,
2361
+ config: {
2362
+ listAccountIds,
2363
+ resolveAccount,
2364
+ setAccountEnabled: ({ cfg, accountId, enabled }: any) =>
2365
+ setAccountEnabledInConfigSection({
2366
+ cfg,
2367
+ sectionKey: CHANNEL_ID,
2368
+ accountId,
2369
+ enabled,
2370
+ allowTopLevel: true,
2371
+ }),
2372
+ isEnabled: (account: any, cfg: any) => {
2373
+ const policy = resolveBncrChannelPolicy(cfg?.channels?.[CHANNEL_ID] || {});
2374
+ return policy.enabled !== false && account?.enabled !== false;
2375
+ },
2376
+ isConfigured: () => true,
2377
+ describeAccount: (account: any) => {
2378
+ const displayName = resolveDefaultDisplayName(account?.name, account?.accountId);
2379
+ return {
2380
+ accountId: account.accountId,
2381
+ name: displayName,
2382
+ enabled: account.enabled !== false,
2383
+ configured: true,
2384
+ };
2385
+ },
2386
+ },
2387
+ setup: {
2388
+ applyAccountName: ({ cfg, accountId, name }: any) =>
2389
+ applyAccountNameToChannelSection({
2390
+ cfg,
2391
+ channelKey: CHANNEL_ID,
2392
+ accountId,
2393
+ name,
2394
+ alwaysUseAccounts: true,
2395
+ }),
2396
+ applyAccountConfig: ({ cfg, accountId }: any) => {
2397
+ const next = { ...(cfg || {}) } as any;
2398
+ next.channels = next.channels || {};
2399
+ next.channels[CHANNEL_ID] = next.channels[CHANNEL_ID] || {};
2400
+ next.channels[CHANNEL_ID].accounts = next.channels[CHANNEL_ID].accounts || {};
2401
+ next.channels[CHANNEL_ID].accounts[accountId] = {
2402
+ ...(next.channels[CHANNEL_ID].accounts[accountId] || {}),
2403
+ enabled: true,
2404
+ };
2405
+ return next;
2406
+ },
2407
+ },
2408
+ outbound: {
2409
+ deliveryMode: 'gateway' as const,
2410
+ textChunkLimit: 4000,
2411
+ sendText: bridge.channelSendText,
2412
+ sendMedia: bridge.channelSendMedia,
2413
+ replyAction: async (ctx: any) => sendBncrReplyAction({
2414
+ accountId: normalizeAccountId(ctx?.accountId),
2415
+ to: asString(ctx?.to || '').trim(),
2416
+ text: asString(ctx?.text || ''),
2417
+ replyToMessageId: asString(ctx?.replyToId || ctx?.replyToMessageId || '').trim() || undefined,
2418
+ sendText: async ({ accountId, to, text }) => bridge.channelSendText({ accountId, to, text }),
2419
+ }),
2420
+ deleteAction: async (ctx: any) => deleteBncrMessageAction({
2421
+ accountId: normalizeAccountId(ctx?.accountId),
2422
+ targetMessageId: asString(ctx?.messageId || ctx?.targetMessageId || '').trim(),
2423
+ }),
2424
+ reactAction: async (ctx: any) => reactBncrMessageAction({
2425
+ accountId: normalizeAccountId(ctx?.accountId),
2426
+ targetMessageId: asString(ctx?.messageId || ctx?.targetMessageId || '').trim(),
2427
+ emoji: asString(ctx?.emoji || '').trim(),
2428
+ }),
2429
+ editAction: async (ctx: any) => editBncrMessageAction({
2430
+ accountId: normalizeAccountId(ctx?.accountId),
2431
+ targetMessageId: asString(ctx?.messageId || ctx?.targetMessageId || '').trim(),
2432
+ text: asString(ctx?.text || ''),
2433
+ }),
2434
+ },
2435
+ status: {
2436
+ defaultRuntime: createDefaultChannelRuntimeState(BNCR_DEFAULT_ACCOUNT_ID, {
2437
+ mode: 'ws-offline',
2438
+ }),
2439
+ buildChannelSummary: async ({ defaultAccountId }: any) => {
2440
+ return bridge.getChannelSummary(defaultAccountId || BNCR_DEFAULT_ACCOUNT_ID);
2441
+ },
2442
+ buildAccountSnapshot: async ({ account, runtime }: any) => {
2443
+ const rt = runtime || bridge.getAccountRuntimeSnapshot(account?.accountId);
2444
+ const meta = rt?.meta || {};
2445
+
2446
+ const pending = Number(rt?.pending ?? meta.pending ?? 0);
2447
+ const deadLetter = Number(rt?.deadLetter ?? meta.deadLetter ?? 0);
2448
+ const lastSessionKey = rt?.lastSessionKey ?? meta.lastSessionKey ?? null;
2449
+ const lastSessionScope = rt?.lastSessionScope ?? meta.lastSessionScope ?? null;
2450
+ const lastSessionAt = rt?.lastSessionAt ?? meta.lastSessionAt ?? null;
2451
+ const lastSessionAgo = rt?.lastSessionAgo ?? meta.lastSessionAgo ?? '-';
2452
+ const lastActivityAt = rt?.lastActivityAt ?? meta.lastActivityAt ?? null;
2453
+ const lastActivityAgo = rt?.lastActivityAgo ?? meta.lastActivityAgo ?? '-';
2454
+ const lastInboundAt = rt?.lastInboundAt ?? meta.lastInboundAt ?? null;
2455
+ const lastInboundAgo = rt?.lastInboundAgo ?? meta.lastInboundAgo ?? '-';
2456
+ const lastOutboundAt = rt?.lastOutboundAt ?? meta.lastOutboundAt ?? null;
2457
+ const lastOutboundAgo = rt?.lastOutboundAgo ?? meta.lastOutboundAgo ?? '-';
2458
+ const diagnostics = rt?.diagnostics ?? meta.diagnostics ?? null;
2459
+ // 右侧状态字段统一:离线时也显示 Status(避免出现 configured 文案)
2460
+ const normalizedMode = rt?.mode === 'linked'
2461
+ ? 'linked'
2462
+ : 'Status';
2463
+
2464
+ const displayName = resolveDefaultDisplayName(account?.name, account?.accountId);
2465
+
2466
+ return {
2467
+ accountId: account.accountId,
2468
+ // default 名不可隐藏时,统一展示稳定默认值
2469
+ name: displayName,
2470
+ enabled: account.enabled !== false,
2471
+ configured: true,
2472
+ linked: Boolean(rt?.connected),
2473
+ running: rt?.running ?? false,
2474
+ connected: rt?.connected ?? false,
2475
+ lastEventAt: rt?.lastEventAt ?? null,
2476
+ lastError: rt?.lastError ?? null,
2477
+ mode: normalizedMode,
2478
+ pending,
2479
+ deadLetter,
2480
+ healthSummary: bridge.getStatusHeadline(account?.accountId),
2481
+ lastSessionKey,
2482
+ lastSessionScope,
2483
+ lastSessionAt,
2484
+ lastSessionAgo,
2485
+ lastActivityAt,
2486
+ lastActivityAgo,
2487
+ lastInboundAt,
2488
+ lastInboundAgo,
2489
+ lastOutboundAt,
2490
+ lastOutboundAgo,
2491
+ diagnostics,
2492
+ };
2493
+ },
2494
+ resolveAccountState: ({ enabled, configured, account, cfg, runtime }: any) => {
2495
+ if (!enabled) return 'disabled';
2496
+ const resolved = resolveAccount(cfg, account?.accountId);
2497
+ if (!(resolved.enabled && configured)) return 'not configured';
2498
+ const rt = runtime || bridge.getAccountRuntimeSnapshot(account?.accountId);
2499
+ return rt?.connected ? 'linked' : 'configured';
2500
+ },
2501
+ },
2502
+ gatewayMethods: [
2503
+ 'bncr.connect',
2504
+ 'bncr.inbound',
2505
+ 'bncr.activity',
2506
+ 'bncr.ack',
2507
+ 'bncr.diagnostics',
2508
+ 'bncr.file.init',
2509
+ 'bncr.file.chunk',
2510
+ 'bncr.file.complete',
2511
+ 'bncr.file.abort',
2512
+ 'bncr.file.ack',
2513
+ ],
2514
+ gateway: {
2515
+ startAccount: bridge.channelStartAccount,
2516
+ stopAccount: bridge.channelStopAccount,
2517
+ },
2518
+ };
2519
+
2520
+ return plugin;
2521
+ }