@xmoxmo/bncr 0.0.4 → 0.0.5

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
@@ -14,9 +14,40 @@ import {
14
14
  writeJsonFileAtomically,
15
15
  readJsonFileWithFallback,
16
16
  } from 'openclaw/plugin-sdk';
17
-
18
- const CHANNEL_ID = 'bncr';
19
- const BNCR_DEFAULT_ACCOUNT_ID = 'Primary';
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';
20
51
  const BRIDGE_VERSION = 2;
21
52
  const BNCR_PUSH_EVENT = 'bncr.push';
22
53
  const CONNECT_TTL_MS = 120_000;
@@ -31,53 +62,6 @@ const FILE_TRANSFER_ACK_TTL_MS = 30_000;
31
62
  const FILE_TRANSFER_KEEP_MS = 6 * 60 * 60 * 1000;
32
63
  const BNCR_DEBUG_VERBOSE = true; // 临时调试:打印发送入口完整请求体
33
64
 
34
- const BncrConfigSchema = {
35
- schema: {
36
- type: 'object',
37
- additionalProperties: true,
38
- properties: {
39
- accounts: {
40
- type: 'object',
41
- additionalProperties: {
42
- type: 'object',
43
- additionalProperties: true,
44
- properties: {
45
- enabled: { type: 'boolean' },
46
- name: { type: 'string' },
47
- },
48
- },
49
- },
50
- },
51
- },
52
- };
53
-
54
- type BncrRoute = {
55
- platform: string;
56
- groupId: string;
57
- userId: string;
58
- };
59
-
60
- type BncrConnection = {
61
- accountId: string;
62
- connId: string;
63
- clientId?: string;
64
- connectedAt: number;
65
- lastSeenAt: number;
66
- };
67
-
68
- type OutboxEntry = {
69
- messageId: string;
70
- accountId: string;
71
- sessionKey: string;
72
- route: BncrRoute;
73
- payload: Record<string, unknown>;
74
- createdAt: number;
75
- retryCount: number;
76
- nextAttemptAt: number;
77
- lastAttemptAt?: number;
78
- lastError?: string;
79
- };
80
-
81
65
  type FileSendTransferState = {
82
66
  transferId: string;
83
67
  accountId: string;
@@ -155,328 +139,12 @@ function asString(v: unknown, fallback = ''): string {
155
139
  return String(v);
156
140
  }
157
141
 
158
- function normalizeAccountId(accountId?: string | null): string {
159
- const v = asString(accountId || '').trim();
160
- if (!v) return BNCR_DEFAULT_ACCOUNT_ID;
161
- const lower = v.toLowerCase();
162
- // 历史兼容:default/primary 统一折叠到 Primary,避免状态尾巴反复出现。
163
- if (lower === 'default' || lower === 'primary') return BNCR_DEFAULT_ACCOUNT_ID;
164
- return v;
165
- }
166
-
167
- function parseRouteFromScope(scope: string): BncrRoute | null {
168
- const parts = asString(scope).trim().split(':');
169
- if (parts.length < 3) return null;
170
- const [platform, groupId, userId] = parts;
171
- if (!platform || !groupId || !userId) return null;
172
- return { platform, groupId, userId };
173
- }
174
-
175
- function parseRouteFromDisplayScope(scope: string): BncrRoute | null {
176
- const raw = asString(scope).trim();
177
- if (!raw) return null;
178
-
179
- // 终版兼容(6 种):
180
- // 1) bncr:g-<hex(scope)>
181
- // 2) bncr:<hex(scope)>
182
- // 3) bncr:<platform>:<groupId>:<userId>
183
- // 4) bncr:g-<platform>:<groupId>:<userId>
184
-
185
- // 1) bncr:g-<hex> 或 bncr:g-<scope>
186
- const gPayload = raw.match(/^bncr:g-(.+)$/i)?.[1];
187
- if (gPayload) {
188
- if (isLowerHex(gPayload)) {
189
- const route = parseRouteFromHexScope(gPayload);
190
- if (route) return route;
191
- }
192
- return parseRouteFromScope(gPayload);
193
- }
194
-
195
- // 2) / 3) bncr:<hex> or bncr:<scope>
196
- const bPayload = raw.match(/^bncr:(.+)$/i)?.[1];
197
- if (bPayload) {
198
- if (isLowerHex(bPayload)) {
199
- const route = parseRouteFromHexScope(bPayload);
200
- if (route) return route;
201
- }
202
- return parseRouteFromScope(bPayload);
203
- }
204
-
205
- return null;
206
- }
207
-
208
- function formatDisplayScope(route: BncrRoute): string {
209
- // 主推荐标签:bncr:<platform>:<groupId>:<userId>
210
- // 保持原始大小写,不做平台名降级。
211
- return `bncr:${route.platform}:${route.groupId}:${route.userId}`;
212
- }
213
-
214
- function isLowerHex(input: string): boolean {
215
- const raw = asString(input).trim();
216
- // 兼容大小写十六进制,不主动降级大小写
217
- return !!raw && /^[0-9a-fA-F]+$/.test(raw) && raw.length % 2 === 0;
218
- }
219
-
220
- function routeScopeToHex(route: BncrRoute): string {
221
- const raw = `${route.platform}:${route.groupId}:${route.userId}`;
222
- return Buffer.from(raw, 'utf8').toString('hex').toLowerCase();
223
- }
224
-
225
- function parseRouteFromHexScope(scopeHex: string): BncrRoute | null {
226
- const rawHex = asString(scopeHex).trim();
227
- if (!isLowerHex(rawHex)) return null;
228
-
229
- try {
230
- const decoded = Buffer.from(rawHex, 'hex').toString('utf8');
231
- return parseRouteFromScope(decoded);
232
- } catch {
233
- return null;
234
- }
235
- }
236
-
237
- function parseRouteLike(input: unknown): BncrRoute | null {
238
- const platform = asString((input as any)?.platform || '').trim();
239
- const groupId = asString((input as any)?.groupId || '').trim();
240
- const userId = asString((input as any)?.userId || '').trim();
241
- if (!platform || !groupId || !userId) return null;
242
- return { platform, groupId, userId };
243
- }
244
-
245
- function parseLegacySessionKeyToStrict(input: string): string | null {
246
- const raw = asString(input).trim();
247
- if (!raw) return null;
248
-
249
- const directLegacy = raw.match(/^agent:main:bncr:direct:([0-9a-fA-F]+):0$/);
250
- if (directLegacy?.[1]) {
251
- const route = parseRouteFromHexScope(directLegacy[1].toLowerCase());
252
- if (route) return buildFallbackSessionKey(route);
253
- }
254
-
255
- const bncrLegacy = raw.match(/^bncr:([0-9a-fA-F]+):0$/);
256
- if (bncrLegacy?.[1]) {
257
- const route = parseRouteFromHexScope(bncrLegacy[1].toLowerCase());
258
- if (route) return buildFallbackSessionKey(route);
259
- }
260
-
261
- const agentLegacy = raw.match(/^agent:main:bncr:([0-9a-fA-F]+):0$/);
262
- if (agentLegacy?.[1]) {
263
- const route = parseRouteFromHexScope(agentLegacy[1].toLowerCase());
264
- if (route) return buildFallbackSessionKey(route);
265
- }
266
-
267
- if (isLowerHex(raw.toLowerCase())) {
268
- const route = parseRouteFromHexScope(raw.toLowerCase());
269
- if (route) return buildFallbackSessionKey(route);
270
- }
271
-
272
- return null;
273
- }
274
-
275
- function isLegacyNoiseRoute(route: BncrRoute): boolean {
276
- const platform = asString(route.platform).trim().toLowerCase();
277
- const groupId = asString(route.groupId).trim().toLowerCase();
278
- const userId = asString(route.userId).trim().toLowerCase();
279
-
280
- // 明确排除历史污染:agent:main:bncr(不是实际外部会话路由)
281
- if (platform === 'agent' && groupId === 'main' && userId === 'bncr') return true;
282
-
283
- // 明确排除嵌套遗留:bncr:<hex>:0(非真实外部 peer)
284
- if (platform === 'bncr' && userId === '0' && isLowerHex(groupId)) return true;
285
-
286
- return false;
287
- }
288
-
289
- function normalizeStoredSessionKey(input: string): { sessionKey: string; route: BncrRoute } | null {
290
- const raw = asString(input).trim();
291
- if (!raw) return null;
292
-
293
- let taskKey: string | null = null;
294
- let base = raw;
295
-
296
- const taskTagged = raw.match(/^(.*):task:([a-z0-9_-]{1,32})$/i);
297
- if (taskTagged) {
298
- base = asString(taskTagged[1]).trim();
299
- taskKey = normalizeTaskKey(taskTagged[2]);
300
- }
301
-
302
- const strict = parseStrictBncrSessionKey(base);
303
- if (strict) {
304
- if (isLegacyNoiseRoute(strict.route)) return null;
305
- return {
306
- sessionKey: taskKey ? `${strict.sessionKey}:task:${taskKey}` : strict.sessionKey,
307
- route: strict.route,
308
- };
309
- }
310
-
311
- const migrated = parseLegacySessionKeyToStrict(base);
312
- if (!migrated) return null;
313
-
314
- const parsed = parseStrictBncrSessionKey(migrated);
315
- if (!parsed) return null;
316
- if (isLegacyNoiseRoute(parsed.route)) return null;
317
-
318
- return {
319
- sessionKey: taskKey ? `${parsed.sessionKey}:task:${taskKey}` : parsed.sessionKey,
320
- route: parsed.route,
321
- };
322
- }
323
-
324
- const BNCR_SESSION_KEY_PREFIX = 'agent:main:bncr:direct:';
325
-
326
- function hex2utf8SessionKey(str: string): { sessionKey: string; scope: string } {
327
- const raw = {
328
- sessionKey: '',
329
- scope: '',
330
- };
331
- if (!str) return raw;
332
-
333
- const strarr = asString(str).trim().split(':');
334
- const newarr: string[] = [];
335
-
336
- for (const s of strarr) {
337
- const part = asString(s).trim();
338
- if (!part) {
339
- newarr.push(part);
340
- continue;
341
- }
342
-
343
- const decoded = Buffer.from(part, 'hex').toString('utf8');
344
- if (decoded?.split(':')?.length === 3) {
345
- newarr.push(decoded);
346
- raw.scope = decoded;
347
- } else {
348
- newarr.push(part);
349
- }
350
- }
351
-
352
- raw.sessionKey = newarr.join(':').trim();
353
- return raw;
354
- }
355
-
356
- function parseStrictBncrSessionKey(input: string): { sessionKey: string; scopeHex: string; route: BncrRoute } | null {
357
- const raw = asString(input).trim();
358
- if (!raw) return null;
359
-
360
- // 终版兼容 sessionKey:
361
- // 1) agent:main:bncr:direct:<hex(scope)>
362
- // 2) agent:main:bncr:group:<hex(scope)>
363
- // (统一归一成 direct:<hex(scope)>)
364
- const m = raw.match(/^agent:main:bncr:(direct|group):(.+)$/);
365
- if (!m?.[1] || !m?.[2]) return null;
366
-
367
- const payload = asString(m[2]).trim();
368
- let route: BncrRoute | null = null;
369
- let scopeHex = '';
370
-
371
- if (isLowerHex(payload)) {
372
- scopeHex = payload;
373
- route = parseRouteFromHexScope(payload);
374
- } else {
375
- route = parseRouteFromScope(payload);
376
- if (route) scopeHex = routeScopeToHex(route);
377
- }
378
-
379
- if (!route || !scopeHex) return null;
380
-
381
- return {
382
- sessionKey: `${BNCR_SESSION_KEY_PREFIX}${scopeHex}`,
383
- scopeHex,
384
- route,
385
- };
386
- }
387
-
388
- function normalizeInboundSessionKey(scope: string, route: BncrRoute): string | null {
389
- const raw = asString(scope).trim();
390
- if (!raw) return buildFallbackSessionKey(route);
391
-
392
- const parsed = parseStrictBncrSessionKey(raw);
393
- if (!parsed) return null;
394
- return parsed.sessionKey;
395
- }
396
-
397
- function normalizeTaskKey(input: unknown): string | null {
398
- const raw = asString(input).trim().toLowerCase();
399
- if (!raw) return null;
400
-
401
- const normalized = raw.replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32);
402
- return normalized || null;
403
- }
404
-
405
- function extractInlineTaskKey(text: string): { taskKey: string | null; text: string } {
406
- const raw = asString(text);
407
- if (!raw) return { taskKey: null, text: '' };
408
-
409
- // 形如:#task:xxx 或 /task:xxx
410
- const tagged = raw.match(/^\s*(?:#task|\/task)\s*[:=]\s*([a-zA-Z0-9_-]{1,32})\s*\n?\s*([\s\S]*)$/i);
411
- if (tagged) {
412
- return {
413
- taskKey: normalizeTaskKey(tagged[1]),
414
- text: asString(tagged[2]),
415
- };
416
- }
417
-
418
- // 形如:/task xxx 后跟正文
419
- const spaced = raw.match(/^\s*\/task\s+([a-zA-Z0-9_-]{1,32})\s+([\s\S]*)$/i);
420
- if (spaced) {
421
- return {
422
- taskKey: normalizeTaskKey(spaced[1]),
423
- text: asString(spaced[2]),
424
- };
425
- }
426
-
427
- return { taskKey: null, text: raw };
428
- }
429
-
430
- function withTaskSessionKey(sessionKey: string, taskKey?: string | null): string {
431
- const base = asString(sessionKey).trim();
432
- const tk = normalizeTaskKey(taskKey);
433
- if (!base || !tk) return base;
434
-
435
- if (/:task:[a-z0-9_-]+(?:$|:)/i.test(base)) return base;
436
- return `${base}:task:${tk}`;
437
- }
438
-
439
- function buildFallbackSessionKey(route: BncrRoute): string {
440
- // 新主口径:sessionKey 使用 agent:main:bncr:direct:<hex(scope)>
441
- return `${BNCR_SESSION_KEY_PREFIX}${routeScopeToHex(route)}`;
442
- }
443
142
 
444
143
  function backoffMs(retryCount: number): number {
445
144
  // 1s,2s,4s,8s... capped by retry count checks
446
145
  return Math.max(1_000, 1_000 * 2 ** Math.max(0, retryCount - 1));
447
146
  }
448
147
 
449
- function inboundDedupKey(params: {
450
- accountId: string;
451
- platform: string;
452
- groupId: string;
453
- userId: string;
454
- msgId?: string;
455
- text?: string;
456
- mediaBase64?: string;
457
- }): string {
458
- const accountId = normalizeAccountId(params.accountId);
459
- const platform = asString(params.platform).trim().toLowerCase();
460
- const groupId = asString(params.groupId).trim();
461
- const userId = asString(params.userId).trim();
462
- const msgId = asString(params.msgId || '').trim();
463
-
464
- if (msgId) return `${accountId}|${platform}|${groupId}|${userId}|msg:${msgId}`;
465
-
466
- const text = asString(params.text || '').trim();
467
- const media = asString(params.mediaBase64 || '');
468
- const digest = createHash('sha1').update(`${text}\n${media.slice(0, 256)}`).digest('hex').slice(0, 16);
469
- return `${accountId}|${platform}|${groupId}|${userId}|hash:${digest}`;
470
- }
471
-
472
- function resolveChatType(_route: BncrRoute): 'direct' | 'group' {
473
- // 业务口径:无论群聊/私聊,统一按 direct 上报,避免会话层落到 group 显示分支(历史 bncr:g-*)。
474
- return 'direct';
475
- }
476
-
477
- function routeKey(accountId: string, route: BncrRoute): string {
478
- return `${accountId}:${route.platform}:${route.groupId}:${route.userId}`.toLowerCase();
479
- }
480
148
 
481
149
  function fileExtFromMime(mimeType?: string): string {
482
150
  const mt = asString(mimeType || '').toLowerCase();
@@ -530,24 +198,6 @@ function resolveOutboundFileName(params: { mediaUrl?: string; fileName?: string;
530
198
  return `${stem}${ext}`;
531
199
  }
532
200
 
533
- function resolveBncrOutboundMessageType(params: { mimeType?: string; fileName?: string; hintedType?: string; hasPayload?: boolean }): 'text' | 'image' | 'video' | 'voice' | 'audio' | 'file' {
534
- const hinted = asString(params.hintedType || '').toLowerCase();
535
- const hasPayload = !!params.hasPayload;
536
- const mt = asString(params.mimeType || '').toLowerCase();
537
- const major = mt.split('/')[0] || '';
538
- const isStandard = hinted === 'text' || hinted === 'image' || hinted === 'video' || hinted === 'voice' || hinted === 'audio' || hinted === 'file';
539
-
540
- // 文本类附件不应落成 text:当上游显式给 text,或上游 type 不在标准列表时,若带文件载荷且 mime 主类型为 text,则归到 file。
541
- if (hasPayload && major === 'text' && (hinted === 'text' || !isStandard)) return 'file';
542
-
543
- // 优先使用上游已给出的标准类型;仅当不在支持列表时再尝试纠正
544
- if (isStandard) return hinted as any;
545
-
546
- if (major === 'text' || major === 'image' || major === 'video' || major === 'audio') return major as any;
547
-
548
- return 'file';
549
- }
550
-
551
201
  class BncrBridgeRuntime {
552
202
  private api: OpenClawPluginApi;
553
203
  private statePath: string | null = null;
@@ -668,42 +318,26 @@ class BncrBridgeRuntime {
668
318
 
669
319
  private buildIntegratedDiagnostics(accountId: string) {
670
320
  const acc = normalizeAccountId(accountId);
671
- const t = now();
672
-
673
- const pending = Array.from(this.outbox.values()).filter((v) => v.accountId === acc).length;
674
- const dead = this.deadLetter.filter((v) => v.accountId === acc).length;
675
- const invalidOutboxSessionKeys = this.countInvalidOutboxSessionKeys(acc);
676
- const legacyAccountResidue = this.countLegacyAccountResidue(acc);
677
-
678
- const totalKnownRoutes = Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc).length;
679
- const connected = this.isOnline(acc);
680
-
681
- const pluginIndexExists = fs.existsSync(path.join(process.cwd(), 'plugins', 'bncr', 'index.ts'));
682
- const pluginChannelExists = fs.existsSync(path.join(process.cwd(), 'plugins', 'bncr', 'src', 'channel.ts'));
683
-
684
- const health = {
685
- connected,
686
- pending,
687
- deadLetter: dead,
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,
688
326
  activeConnections: this.activeConnectionCount(acc),
689
327
  connectEvents: this.getCounter(this.connectEventsByAccount, acc),
690
328
  inboundEvents: this.getCounter(this.inboundEventsByAccount, acc),
691
329
  activityEvents: this.getCounter(this.activityEventsByAccount, acc),
692
330
  ackEvents: this.getCounter(this.ackEventsByAccount, acc),
693
- uptimeSec: Math.floor((t - this.startedAt) / 1000),
694
- };
695
-
696
- const regression = {
697
- pluginFilesPresent: pluginIndexExists && pluginChannelExists,
698
- pluginIndexExists,
699
- pluginChannelExists,
700
- totalKnownRoutes,
701
- invalidOutboxSessionKeys,
702
- legacyAccountResidue,
703
- ok: invalidOutboxSessionKeys === 0 && legacyAccountResidue === 0,
704
- };
705
-
706
- return { health, regression };
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
+ });
707
341
  }
708
342
 
709
343
  private async loadState() {
@@ -1204,10 +838,10 @@ class BncrBridgeRuntime {
1204
838
  return alias?.route || parsed.route;
1205
839
  }
1206
840
 
1207
- // 严谨目标解析(终版兼容模式):
1208
- // 1) 兼容 6 种输入格式(to/sessionKey)
1209
- // 2) 统一反查并归一为 sessionKey=agent:main:bncr:direct:<hex(scope)>
1210
- // 3) 非兼容格式直接失败,并输出标准格式提示日志
841
+ // 严谨目标解析:
842
+ // 1) 标准 to 仅认 Bncr:<platform>:<groupId>:<userId> / Bncr:<platform>:<userId>
843
+ // 2) 仍接受 strict sessionKey 作为内部兼容输入
844
+ // 3) 其他旧格式直接失败,并输出标准格式提示日志
1211
845
  private resolveVerifiedTarget(rawTarget: string, accountId: string): { sessionKey: string; route: BncrRoute; displayScope: string } {
1212
846
  const acc = normalizeAccountId(accountId);
1213
847
  const raw = asString(rawTarget).trim();
@@ -1226,9 +860,9 @@ class BncrBridgeRuntime {
1226
860
 
1227
861
  if (!route) {
1228
862
  this.api.logger.warn?.(
1229
- `[bncr-target-invalid] raw=${raw} accountId=${acc} reason=unparseable-or-unknown standardTo=bncr:<platform>:<groupId>:<userId> standardSessionKey=agent:main:bncr:direct:<hex(scope)>`,
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)>`,
1230
864
  );
1231
- throw new Error(`bncr invalid target(standard: to=bncr:<platform>:<groupId>:<userId>): ${raw}`);
865
+ throw new Error(`bncr invalid target(standard: Bncr:<platform>:<groupId>:<userId> | Bncr:<platform>:<userId>): ${raw}`);
1232
866
  }
1233
867
 
1234
868
  const wantedRouteKey = routeKey(acc, route);
@@ -1355,87 +989,77 @@ class BncrBridgeRuntime {
1355
989
  return { path: finalPath, fileSha256: sha };
1356
990
  }
1357
991
 
1358
- private fmtAgo(ts?: number | null): string {
1359
- if (!ts || !Number.isFinite(ts) || ts <= 0) return '-';
1360
- const diff = Math.max(0, now() - ts);
1361
- if (diff < 1_000) return 'just now';
1362
- if (diff < 60_000) return `${Math.floor(diff / 1_000)}s ago`;
1363
- if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
1364
- if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
1365
- return `${Math.floor(diff / 86_400_000)}d ago`;
1366
- }
1367
-
1368
992
  private buildStatusMeta(accountId: string) {
1369
993
  const acc = normalizeAccountId(accountId);
1370
- const pending = Array.from(this.outbox.values()).filter((v) => v.accountId === acc).length;
1371
- const dead = this.deadLetter.filter((v) => v.accountId === acc).length;
1372
- const last = this.lastSessionByAccount.get(acc);
1373
- const lastActAt = this.lastActivityByAccount.get(acc) || null;
1374
- const lastInboundAt = this.lastInboundByAccount.get(acc) || null;
1375
- const lastOutboundAt = this.lastOutboundByAccount.get(acc) || null;
1376
-
1377
- const lastSessionAgo = this.fmtAgo(last?.updatedAt || null);
1378
- const lastActivityAgo = this.fmtAgo(lastActAt);
1379
- const lastInboundAgo = this.fmtAgo(lastInboundAt);
1380
- const lastOutboundAgo = this.fmtAgo(lastOutboundAt);
1381
- const diagnostics = this.buildIntegratedDiagnostics(acc);
1382
-
1383
- return {
1384
- pending,
1385
- deadLetter: dead,
1386
- lastSessionKey: last?.sessionKey || null,
1387
- lastSessionScope: last?.scope || null,
1388
- lastSessionAt: last?.updatedAt || null,
1389
- lastSessionAgo,
1390
- lastActivityAt: lastActAt,
1391
- lastActivityAgo,
1392
- lastInboundAt,
1393
- lastInboundAgo,
1394
- lastOutboundAt,
1395
- lastOutboundAgo,
1396
- diagnostics,
1397
- };
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
+ });
1398
1014
  }
1399
1015
 
1400
1016
  getAccountRuntimeSnapshot(accountId: string) {
1401
1017
  const acc = normalizeAccountId(accountId);
1402
- const connected = this.isOnline(acc);
1403
- const lastEventAt = this.lastActivityByAccount.get(acc) || null;
1404
- const lastInboundAt = this.lastInboundByAccount.get(acc) || null;
1405
- const lastOutboundAt = this.lastOutboundByAccount.get(acc) || null;
1406
- return {
1018
+ return buildAccountRuntimeSnapshot({
1407
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),
1408
1036
  running: true,
1409
- connected,
1410
- linked: connected,
1411
- lastEventAt,
1412
- lastInboundAt,
1413
- lastOutboundAt,
1414
- // 状态映射:在线=linked,离线=configured
1415
- mode: connected ? 'linked' : 'configured',
1416
- meta: this.buildStatusMeta(acc),
1417
- };
1037
+ channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
1038
+ });
1418
1039
  }
1419
1040
 
1420
1041
  private buildStatusHeadline(accountId: string): string {
1421
1042
  const acc = normalizeAccountId(accountId);
1422
- const diag = this.buildIntegratedDiagnostics(acc);
1423
- const h = diag.health;
1424
- const r = diag.regression;
1425
-
1426
- const parts = [
1427
- r.ok ? 'diag:ok' : 'diag:warn',
1428
- `p:${h.pending}`,
1429
- `d:${h.deadLetter}`,
1430
- `c:${h.activeConnections}`,
1431
- ];
1432
-
1433
- if (!r.ok) {
1434
- if (r.invalidOutboxSessionKeys > 0) parts.push(`invalid:${r.invalidOutboxSessionKeys}`);
1435
- if (r.legacyAccountResidue > 0) parts.push(`legacy:${r.legacyAccountResidue}`);
1436
- }
1437
-
1438
- return parts.join(' ');
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
+ });
1439
1063
  }
1440
1064
 
1441
1065
  getStatusHeadline(accountId: string): string {
@@ -1762,33 +1386,20 @@ class BncrBridgeRuntime {
1762
1386
  });
1763
1387
  const messageId = randomUUID();
1764
1388
  const mediaMsg = first ? asString(payload.text || '') : '';
1765
- const frame = {
1766
- type: 'message.outbound',
1389
+ const frame = buildBncrMediaOutboundFrame({
1767
1390
  messageId,
1768
- idempotencyKey: messageId,
1769
1391
  sessionKey,
1770
- message: {
1771
- platform: route.platform,
1772
- groupId: route.groupId,
1773
- userId: route.userId,
1774
- type: resolveBncrOutboundMessageType({
1775
- mimeType: media.mimeType,
1776
- fileName: media.fileName,
1777
- hasPayload: !!(media.path || media.mediaBase64),
1778
- }),
1779
- mimeType: media.mimeType || '',
1780
- msg: mediaMsg,
1781
- path: media.path || mediaUrl,
1782
- base64: media.mediaBase64 || '',
1783
- fileName: resolveOutboundFileName({
1784
- mediaUrl,
1785
- fileName: media.fileName,
1786
- mimeType: media.mimeType,
1787
- }),
1788
- transferMode: media.mode,
1789
- },
1790
- ts: now(),
1791
- };
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
+ });
1792
1403
 
1793
1404
  this.enqueueOutbound({
1794
1405
  messageId,
@@ -1939,14 +1550,33 @@ class BncrBridgeRuntime {
1939
1550
 
1940
1551
  handleDiagnostics = async ({ params, respond }: GatewayRequestHandlerOptions) => {
1941
1552
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
1553
+ const cfg = await this.api.runtime.config.loadConfig();
1942
1554
  const runtime = this.getAccountRuntimeSnapshot(accountId);
1943
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
+ });
1944
1572
 
1945
1573
  respond(true, {
1946
1574
  channel: CHANNEL_ID,
1947
1575
  accountId,
1948
1576
  runtime,
1949
1577
  diagnostics,
1578
+ permissions,
1579
+ probe,
1950
1580
  now: now(),
1951
1581
  });
1952
1582
  };
@@ -2231,7 +1861,8 @@ class BncrBridgeRuntime {
2231
1861
  };
2232
1862
 
2233
1863
  handleInbound = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
2234
- const accountId = normalizeAccountId(asString(params?.accountId || ''));
1864
+ const parsed = parseBncrInboundParams(params);
1865
+ const { accountId, platform, groupId, userId, sessionKeyfromroute, route, text, msgType, mediaBase64, mediaPathFromTransfer, mimeType, fileName, msgId, dedupKey, peer, extracted } = parsed;
2235
1866
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2236
1867
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2237
1868
  this.rememberGatewayContext(context);
@@ -2239,39 +1870,10 @@ class BncrBridgeRuntime {
2239
1870
  this.markActivity(accountId);
2240
1871
  this.incrementCounter(this.inboundEventsByAccount, accountId);
2241
1872
 
2242
- const platform = asString(params?.platform || '').trim();
2243
- const groupId = asString(params?.groupId || '0').trim() || '0';
2244
- const userId = asString(params?.userId || '').trim();
2245
- const sessionKeyfromroute = asString(params?.sessionKey || '').trim();
2246
-
2247
1873
  if (!platform || (!userId && !groupId)) {
2248
1874
  respond(false, { error: 'platform/groupId/userId required' });
2249
1875
  return;
2250
1876
  }
2251
-
2252
- const route: BncrRoute = {
2253
- platform,
2254
- groupId,
2255
- userId,
2256
- };
2257
-
2258
- const text = asString(params?.msg || '');
2259
- const msgType = asString(params?.type || 'text') || 'text';
2260
- const mediaBase64 = asString(params?.base64 || '');
2261
- const mediaPathFromTransfer = asString(params?.path || '').trim();
2262
- const mimeType = asString(params?.mimeType || '').trim() || undefined;
2263
- const fileName = asString(params?.fileName || '').trim() || undefined;
2264
- const msgId = asString(params?.msgId || '').trim() || undefined;
2265
-
2266
- const dedupKey = inboundDedupKey({
2267
- accountId,
2268
- platform,
2269
- groupId,
2270
- userId,
2271
- msgId,
2272
- text,
2273
- mediaBase64,
2274
- });
2275
1877
  if (this.markInboundDedupSeen(dedupKey)) {
2276
1878
  respond(true, {
2277
1879
  accepted: true,
@@ -2282,34 +1884,32 @@ class BncrBridgeRuntime {
2282
1884
  return;
2283
1885
  }
2284
1886
 
2285
- const peer = {
2286
- kind: resolveChatType(route),
2287
- id: route.groupId === '0' ? route.userId : route.groupId,
2288
- } as const;
2289
-
2290
1887
  const cfg = await this.api.runtime.config.loadConfig();
2291
- const resolvedRoute = this.api.runtime.channel.routing.resolveAgentRoute({
1888
+ const gate = checkBncrMessageGate({
1889
+ parsed,
2292
1890
  cfg,
2293
- channel: CHANNEL_ID,
2294
- accountId,
2295
- peer,
1891
+ account: resolveAccount(cfg, accountId),
2296
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
+ }
2297
1902
 
2298
- const baseSessionKey = normalizeInboundSessionKey(sessionKeyfromroute, route) || resolvedRoute.sessionKey;
2299
-
2300
- // 轻量任务拆分:允许在消息前缀中声明 task key,将任务分流到子会话,降低单会话上下文压力。
2301
- // 支持:#task:foo / /task:foo / /task foo <正文>
2302
- const extracted = extractInlineTaskKey(text);
2303
- const agentText = extracted.text;
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;
2304
1910
  const taskSessionKey = withTaskSessionKey(baseSessionKey, extracted.taskKey);
2305
1911
  const sessionKey = taskSessionKey || baseSessionKey;
2306
1912
 
2307
- this.rememberSessionRoute(baseSessionKey, accountId, route);
2308
- if (taskSessionKey && taskSessionKey !== baseSessionKey) {
2309
- this.rememberSessionRoute(taskSessionKey, accountId, route);
2310
- }
2311
-
2312
- // 先回 ACK,后异步处理 AI 回复
2313
1913
  respond(true, {
2314
1914
  accepted: true,
2315
1915
  accountId,
@@ -2318,114 +1918,22 @@ class BncrBridgeRuntime {
2318
1918
  taskKey: extracted.taskKey ?? null,
2319
1919
  });
2320
1920
 
2321
- void (async () => {
2322
- try {
2323
- const storePath = this.api.runtime.channel.session.resolveStorePath(cfg?.session?.store, {
2324
- agentId: resolvedRoute.agentId,
2325
- });
2326
-
2327
- let mediaPath: string | undefined;
2328
- if (mediaBase64) {
2329
- const mediaBuf = Buffer.from(mediaBase64, 'base64');
2330
- const saved = await this.api.runtime.channel.media.saveMediaBuffer(
2331
- mediaBuf,
2332
- mimeType,
2333
- 'inbound',
2334
- 30 * 1024 * 1024,
2335
- fileName,
2336
- );
2337
- mediaPath = saved.path;
2338
- } else if (mediaPathFromTransfer && fs.existsSync(mediaPathFromTransfer)) {
2339
- mediaPath = mediaPathFromTransfer;
2340
- }
2341
-
2342
- const rawBody = agentText || (msgType === 'text' ? '' : `[${msgType}]`);
2343
- const body = this.api.runtime.channel.reply.formatAgentEnvelope({
2344
- channel: 'Bncr',
2345
- from: `${platform}:${groupId}:${userId}`,
2346
- timestamp: Date.now(),
2347
- previousTimestamp: this.api.runtime.channel.session.readSessionUpdatedAt({
2348
- storePath,
2349
- sessionKey,
2350
- }),
2351
- envelope: this.api.runtime.channel.reply.resolveEnvelopeFormatOptions(cfg),
2352
- body: rawBody,
2353
- });
2354
-
2355
- const displayTo = formatDisplayScope(route);
2356
- // 全场景口径:SenderId 统一上报为 hex(scope)
2357
- // 作为平台侧稳定身份键,避免退化为裸 userId。
2358
- const senderIdForContext =
2359
- parseStrictBncrSessionKey(baseSessionKey)?.scopeHex
2360
- || routeScopeToHex(route);
2361
- const ctxPayload = this.api.runtime.channel.reply.finalizeInboundContext({
2362
- Body: body,
2363
- BodyForAgent: rawBody,
2364
- RawBody: rawBody,
2365
- CommandBody: rawBody,
2366
- MediaPath: mediaPath,
2367
- MediaType: mimeType,
2368
- From: `${CHANNEL_ID}:${platform}:${groupId}:${userId}`,
2369
- To: displayTo,
2370
- SessionKey: sessionKey,
2371
- AccountId: accountId,
2372
- ChatType: peer.kind,
2373
- ConversationLabel: displayTo,
2374
- SenderId: senderIdForContext,
2375
- Provider: CHANNEL_ID,
2376
- Surface: CHANNEL_ID,
2377
- MessageSid: msgId,
2378
- Timestamp: Date.now(),
2379
- OriginatingChannel: CHANNEL_ID,
2380
- OriginatingTo: displayTo,
2381
- });
2382
-
2383
- await this.api.runtime.channel.session.recordInboundSession({
2384
- storePath,
2385
- sessionKey,
2386
- ctx: ctxPayload,
2387
- onRecordError: (err) => {
2388
- this.api.logger.warn?.(`bncr: record session failed: ${String(err)}`);
2389
- },
2390
- });
2391
-
2392
- // 记录真正的业务活动时间(入站已完成解析并落会话)
2393
- const inboundAt = now();
2394
- this.lastInboundByAccount.set(accountId, inboundAt);
2395
- this.markActivity(accountId, inboundAt);
2396
- this.scheduleSave();
2397
-
2398
- await this.api.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
2399
- ctx: ctxPayload,
2400
- cfg,
2401
- // BNCR 侧仅推送最终回复,不要流式 block 推送
2402
- replyOptions: {
2403
- disableBlockStreaming: true,
2404
- },
2405
- dispatcherOptions: {
2406
- deliver: async (
2407
- payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] },
2408
- info?: { kind?: 'tool' | 'block' | 'final' },
2409
- ) => {
2410
- // 过滤掉流式 block/tool,仅投递 final
2411
- if (info?.kind && info.kind !== 'final') return;
2412
-
2413
- await this.enqueueFromReply({
2414
- accountId,
2415
- sessionKey,
2416
- route,
2417
- payload,
2418
- });
2419
- },
2420
- onError: (err: unknown) => {
2421
- this.api.logger.error?.(`bncr reply failed: ${String(err)}`);
2422
- },
2423
- },
2424
- });
2425
- } catch (err) {
2426
- this.api.logger.error?.(`bncr inbound process failed: ${String(err)}`);
2427
- }
2428
- })();
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
+ });
2429
1937
  };
2430
1938
 
2431
1939
  channelStartAccount = async (ctx: any) => {
@@ -2494,21 +2002,17 @@ class BncrBridgeRuntime {
2494
2002
  );
2495
2003
  }
2496
2004
 
2497
- const verified = this.resolveVerifiedTarget(to, accountId);
2498
-
2499
- this.rememberSessionRoute(verified.sessionKey, accountId, verified.route);
2500
-
2501
- await this.enqueueFromReply({
2005
+ return sendBncrText({
2006
+ channelId: CHANNEL_ID,
2502
2007
  accountId,
2503
- sessionKey: verified.sessionKey,
2504
- route: verified.route,
2505
- payload: {
2506
- text: asString(ctx.text || ''),
2507
- },
2008
+ to,
2009
+ text: asString(ctx.text || ''),
2508
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(),
2509
2015
  });
2510
-
2511
- return { channel: CHANNEL_ID, messageId: randomUUID(), chatId: verified.sessionKey };
2512
2016
  };
2513
2017
 
2514
2018
  channelSendMedia = async (ctx: any) => {
@@ -2535,57 +2039,21 @@ class BncrBridgeRuntime {
2535
2039
  );
2536
2040
  }
2537
2041
 
2538
- const verified = this.resolveVerifiedTarget(to, accountId);
2539
-
2540
- this.rememberSessionRoute(verified.sessionKey, accountId, verified.route);
2541
-
2542
- await this.enqueueFromReply({
2042
+ return sendBncrMedia({
2043
+ channelId: CHANNEL_ID,
2543
2044
  accountId,
2544
- sessionKey: verified.sessionKey,
2545
- route: verified.route,
2546
- payload: {
2547
- text: asString(ctx.text || ''),
2548
- mediaUrl: asString(ctx.mediaUrl || ''),
2549
- },
2045
+ to,
2046
+ text: asString(ctx.text || ''),
2047
+ mediaUrl: asString(ctx.mediaUrl || ''),
2550
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(),
2551
2053
  });
2552
-
2553
- return { channel: CHANNEL_ID, messageId: randomUUID(), chatId: verified.sessionKey };
2554
2054
  };
2555
2055
  }
2556
2056
 
2557
- function resolveDefaultDisplayName(rawName: unknown, accountId: string): string {
2558
- const raw = asString(rawName || '').trim();
2559
- // 统一兜底:空名 / 与 accountId 重复 / 历史默认名 => Monitor
2560
- if (!raw || raw === accountId || /^bncr$/i.test(raw) || /^status$/i.test(raw) || /^runtime$/i.test(raw)) return 'Monitor';
2561
- return raw;
2562
- }
2563
-
2564
- function resolveAccount(cfg: any, accountId?: string | null) {
2565
- const accounts = cfg?.channels?.[CHANNEL_ID]?.accounts || {};
2566
- let key = normalizeAccountId(accountId);
2567
-
2568
- // 若请求的 accountId 不存在(例如框架仍传 default),回退到首个已配置账号
2569
- if (!accounts[key]) {
2570
- const first = Object.keys(accounts)[0];
2571
- if (first) key = first;
2572
- }
2573
-
2574
- const account = accounts[key] || {};
2575
- const displayName = resolveDefaultDisplayName(account?.name, key);
2576
- return {
2577
- accountId: key,
2578
- // accountId(default) 无法隐藏时,给稳定默认名,避免空名或 default(default)
2579
- name: displayName,
2580
- enabled: account?.enabled !== false,
2581
- };
2582
- }
2583
-
2584
- function listAccountIds(cfg: any): string[] {
2585
- const ids = Object.keys(cfg?.channels?.[CHANNEL_ID]?.accounts || {});
2586
- return ids.length ? ids : [BNCR_DEFAULT_ACCOUNT_ID];
2587
- }
2588
-
2589
2057
  export function createBncrBridge(api: OpenClawPluginApi) {
2590
2058
  return new BncrBridgeRuntime(api);
2591
2059
  }
@@ -2605,7 +2073,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2605
2073
  chatTypes: ['direct'] as ChatType[],
2606
2074
  media: true,
2607
2075
  reply: true,
2608
- nativeCommands: false,
2076
+ nativeCommands: true,
2609
2077
  },
2610
2078
  messaging: {
2611
2079
  // 接收任意标签输入;不在 normalize 阶段做格式门槛,统一下沉到发送前验证。
@@ -2617,7 +2085,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2617
2085
  looksLikeId: (raw: string, normalized?: string) => {
2618
2086
  return Boolean(asString(normalized || raw).trim());
2619
2087
  },
2620
- hint: 'Compat(6): agent:main:bncr:direct:<hex>, agent:main:bncr:group:<hex>, bncr:<hex>, bncr:g-<hex>, bncr:<platform>:<group>:<user>, bncr:g-<platform>:<group>:<user>; preferred to=bncr:<platform>:<group>:<user>, canonical sessionKey=agent:main:bncr:direct:<hex>',
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>',
2621
2089
  },
2622
2090
  },
2623
2091
  configSchema: BncrConfigSchema,
@@ -2632,7 +2100,10 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2632
2100
  enabled,
2633
2101
  allowTopLevel: true,
2634
2102
  }),
2635
- isEnabled: (account: any) => account?.enabled !== false,
2103
+ isEnabled: (account: any, cfg: any) => {
2104
+ const policy = resolveBncrChannelPolicy(cfg?.channels?.[CHANNEL_ID] || {});
2105
+ return policy.enabled !== false && account?.enabled !== false;
2106
+ },
2636
2107
  isConfigured: () => true,
2637
2108
  describeAccount: (account: any) => {
2638
2109
  const displayName = resolveDefaultDisplayName(account?.name, account?.accountId);
@@ -2670,6 +2141,27 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2670
2141
  textChunkLimit: 4000,
2671
2142
  sendText: bridge.channelSendText,
2672
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
+ }),
2673
2165
  },
2674
2166
  status: {
2675
2167
  defaultRuntime: createDefaultChannelRuntimeState(BNCR_DEFAULT_ACCOUNT_ID, {