@xmoxmo/bncr 0.5.6 → 0.5.8

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.
Files changed (90) hide show
  1. package/dist/index.js +1 -9
  2. package/index.ts +1 -9
  3. package/package.json +1 -1
  4. package/scripts/check-pack.mjs +0 -8
  5. package/scripts/selfcheck.mjs +0 -10
  6. package/src/bootstrap/runtime-loader.ts +1 -1
  7. package/src/channel.ts +118 -171
  8. package/src/core/accounts.ts +1 -6
  9. package/src/core/connection-reachability.ts +2 -6
  10. package/src/core/dead-letter-diagnostics.ts +4 -10
  11. package/src/core/logging.ts +1 -1
  12. package/src/core/outbox-entry-builders.ts +78 -84
  13. package/src/core/outbox-file-transfer-failure.ts +5 -0
  14. package/src/core/outbox-file-transfer-guards.ts +6 -0
  15. package/src/core/outbox-file-transfer-prep.ts +8 -5
  16. package/src/core/outbox-file-transfer-success.ts +5 -0
  17. package/src/core/outbox-queue.ts +1 -7
  18. package/src/core/outbox-summary.ts +10 -56
  19. package/src/core/permissions.ts +2 -10
  20. package/src/core/persisted-outbox-entry.ts +54 -11
  21. package/src/core/policy.ts +1 -11
  22. package/src/core/register-trace.ts +4 -7
  23. package/src/core/status-meta.ts +1 -9
  24. package/src/core/status.ts +1 -21
  25. package/src/core/targets.ts +52 -70
  26. package/src/core/types.ts +1 -17
  27. package/src/core/value-sanitize.ts +38 -1
  28. package/src/messaging/inbound/commands.ts +2 -24
  29. package/src/messaging/inbound/dispatch-prep.ts +1 -1
  30. package/src/messaging/inbound/gate.ts +2 -31
  31. package/src/messaging/inbound/group-history.ts +0 -75
  32. package/src/messaging/inbound/parse.ts +2 -32
  33. package/src/messaging/inbound/scene-admin.ts +8 -8
  34. package/src/messaging/outbound/diagnostics-debug-builders.ts +0 -15
  35. package/src/messaging/outbound/diagnostics.ts +1 -7
  36. package/src/messaging/outbound/durable-queue-adapter.ts +17 -11
  37. package/src/messaging/outbound/marker-parser.ts +277 -28
  38. package/src/messaging/outbound/media-dedupe.ts +3 -10
  39. package/src/messaging/outbound/media.ts +1 -6
  40. package/src/messaging/outbound/normalize-outbound-send.ts +329 -0
  41. package/src/messaging/outbound/queue-selectors.ts +1 -5
  42. package/src/messaging/outbound/reply-enqueue-media.ts +1 -184
  43. package/src/messaging/outbound/reply-enqueue.ts +313 -175
  44. package/src/messaging/outbound/session-route.ts +6 -11
  45. package/src/messaging/outbound/target-resolver.ts +1 -6
  46. package/src/openclaw/media-runtime.ts +1 -1
  47. package/src/openclaw/routing-runtime.ts +0 -11
  48. package/src/openclaw/sdk-helpers.ts +0 -2
  49. package/src/plugin/bridge-media-facade.ts +0 -4
  50. package/src/plugin/bridge-outbox-facade.ts +0 -1
  51. package/src/plugin/channel-components.ts +0 -13
  52. package/src/plugin/channel-plugin-surface-group.ts +52 -65
  53. package/src/plugin/channel-runtime-builders-delivery.ts +3 -362
  54. package/src/plugin/channel-runtime-builders-status.ts +0 -160
  55. package/src/plugin/channel-runtime-builders.ts +3 -17
  56. package/src/plugin/channel-runtime-constants.ts +1 -1
  57. package/src/plugin/channel-runtime-types.ts +2 -24
  58. package/src/plugin/channel-send.ts +206 -221
  59. package/src/plugin/channel-utils.ts +8 -29
  60. package/src/plugin/connection-handlers-helpers.ts +0 -15
  61. package/src/plugin/connection-handlers.ts +6 -5
  62. package/src/plugin/file-inbound-abort.ts +5 -0
  63. package/src/plugin/file-inbound-chunk.ts +5 -0
  64. package/src/plugin/file-inbound-complete.ts +5 -0
  65. package/src/plugin/file-transfer-orchestrator.ts +39 -2
  66. package/src/plugin/media-dedupe-runtime.ts +1 -6
  67. package/src/plugin/media-orchestrators-runtime-group.ts +20 -149
  68. package/src/plugin/message-send.ts +7 -8
  69. package/src/plugin/messaging.ts +3 -3
  70. package/src/plugin/outbound.ts +6 -26
  71. package/src/plugin/outbox-drain-loop.ts +2 -2
  72. package/src/plugin/outbox-push.ts +1 -52
  73. package/src/plugin/outbox-unified-push-flow.ts +307 -0
  74. package/src/plugin/scene-registry.ts +1 -1
  75. package/src/plugin/target-runtime.ts +1 -6
  76. package/src/runtime/log-dedupe.ts +3 -3
  77. package/src/runtime/outbound-ack-timeout.ts +3 -12
  78. package/src/runtime/outbox-transitions.ts +1 -4
  79. package/src/runtime/status-snapshots.ts +2 -2
  80. package/src/runtime/status-worker.ts +3 -15
  81. package/src/core/outbox-text-push-prep.ts +0 -36
  82. package/src/messaging/outbound/build-send-action.ts +0 -130
  83. package/src/messaging/outbound/durable-message-adapter.ts +0 -121
  84. package/src/messaging/outbound/send-params.ts +0 -114
  85. package/src/messaging/outbound/send.ts +0 -127
  86. package/src/openclaw/ingress-runtime.ts +0 -25
  87. package/src/plugin/bridge-file-transfer-push-facade.ts +0 -142
  88. package/src/plugin/channel-send-runtime-group.ts +0 -37
  89. package/src/plugin/outbox-file-push-flow.ts +0 -69
  90. package/src/plugin/outbox-text-push-flow.ts +0 -90
@@ -1,4 +1,5 @@
1
1
  import type { BncrRoute } from './types.ts';
2
+ import { asString } from './value-sanitize.ts';
2
3
 
3
4
  type RouteInputLike = {
4
5
  platform?: unknown;
@@ -28,14 +29,8 @@ export type BncrExplicitTarget = {
28
29
  groupId?: string;
29
30
  };
30
31
 
31
- export function asTargetString(value: unknown, fallback = ''): string {
32
- if (typeof value === 'string') return value;
33
- if (value == null) return fallback;
34
- return String(value);
35
- }
36
-
37
- export function parseRouteFromStandardDisplayScope(scope: string): BncrRoute | null {
38
- const parts = asTargetString(scope).trim().split(':');
32
+ function parseRouteFromStandardDisplayScope(scope: string): BncrRoute | null {
33
+ const parts = asString(scope).trim().split(':');
39
34
  if (parts.length === 3) {
40
35
  const [platform, kind, id] = parts;
41
36
  if (!platform || !kind || !id) return null;
@@ -65,7 +60,7 @@ export function parseRouteFromStandardDisplayScope(scope: string): BncrRoute | n
65
60
  }
66
61
 
67
62
  function parseGroupScope(scope: string): { platform: string; groupId: string } | null {
68
- const parts = asTargetString(scope).trim().split(':');
63
+ const parts = asString(scope).trim().split(':');
69
64
  if (parts.length !== 2) return null;
70
65
  const [platform, groupId] = parts;
71
66
  if (!platform || !groupId) return null;
@@ -73,15 +68,15 @@ function parseGroupScope(scope: string): { platform: string; groupId: string } |
73
68
  }
74
69
 
75
70
  function parseDirectScope(scope: string): { platform: string; userId: string } | null {
76
- const parts = asTargetString(scope).trim().split(':');
71
+ const parts = asString(scope).trim().split(':');
77
72
  if (parts.length !== 2) return null;
78
73
  const [platform, userId] = parts;
79
74
  if (!platform || !userId) return null;
80
75
  return { platform, userId };
81
76
  }
82
77
 
83
- export function normalizeDisplayScopePrefix(scope: string): string {
84
- const raw = asTargetString(scope).trim();
78
+ function normalizeDisplayScopePrefix(scope: string): string {
79
+ const raw = asString(scope).trim();
85
80
  if (!raw) return '';
86
81
  if (raw.startsWith('Bncr:')) return raw;
87
82
  if (/^bncr[:-]/i.test(raw)) return raw;
@@ -89,11 +84,7 @@ export function normalizeDisplayScopePrefix(scope: string): string {
89
84
  return `Bncr:${raw}`;
90
85
  }
91
86
 
92
- export function resolveCanonicalSessionKind(): BncrSessionKind {
93
- return 'direct';
94
- }
95
-
96
- export function buildExplicitTargetResult(args: {
87
+ function buildExplicitTargetResult(args: {
97
88
  raw: string;
98
89
  source:
99
90
  | 'display-scope'
@@ -121,8 +112,8 @@ export function buildExplicitTargetResult(args: {
121
112
  };
122
113
  }
123
114
 
124
- export function isLowerHex(input: string): boolean {
125
- const raw = asTargetString(input).trim();
115
+ function isLowerHex(input: string): boolean {
116
+ const raw = asString(input).trim();
126
117
  return !!raw && /^[0-9a-fA-F]+$/.test(raw) && raw.length % 2 === 0;
127
118
  }
128
119
 
@@ -131,18 +122,18 @@ export function routeScopeToHex(route: BncrRoute): string {
131
122
  return Buffer.from(raw, 'utf8').toString('hex').toLowerCase();
132
123
  }
133
124
 
134
- export function groupScopeToHex(route: BncrRoute): string {
125
+ function groupScopeToHex(route: BncrRoute): string {
135
126
  const raw = `${route.platform}:${route.groupId}`;
136
127
  return Buffer.from(raw, 'utf8').toString('hex').toLowerCase();
137
128
  }
138
129
 
139
- export function directScopeToHex(route: BncrRoute): string {
130
+ function directScopeToHex(route: BncrRoute): string {
140
131
  const raw = `${route.platform}:${route.userId}`;
141
132
  return Buffer.from(raw, 'utf8').toString('hex').toLowerCase();
142
133
  }
143
134
 
144
- export function parseRouteFromScope(scope: string): BncrRoute | null {
145
- const parts = asTargetString(scope).trim().split(':');
135
+ function parseRouteFromScope(scope: string): BncrRoute | null {
136
+ const parts = asString(scope).trim().split(':');
146
137
  if (parts.length < 3) return null;
147
138
  const [platform, groupId, userId] = parts;
148
139
  if (!platform || !groupId || !userId) return null;
@@ -150,8 +141,8 @@ export function parseRouteFromScope(scope: string): BncrRoute | null {
150
141
  return { platform, groupId, userId };
151
142
  }
152
143
 
153
- export function parseRouteFromHexScope(scopeHex: string): BncrRoute | null {
154
- const rawHex = asTargetString(scopeHex).trim();
144
+ function parseRouteFromHexScope(scopeHex: string): BncrRoute | null {
145
+ const rawHex = asString(scopeHex).trim();
155
146
  if (!isLowerHex(rawHex)) return null;
156
147
 
157
148
  try {
@@ -162,8 +153,8 @@ export function parseRouteFromHexScope(scopeHex: string): BncrRoute | null {
162
153
  }
163
154
  }
164
155
 
165
- export function parseGroupRouteFromHexScope(scopeHex: string): BncrRoute | null {
166
- const rawHex = asTargetString(scopeHex).trim();
156
+ function parseGroupRouteFromHexScope(scopeHex: string): BncrRoute | null {
157
+ const rawHex = asString(scopeHex).trim();
167
158
  if (!isLowerHex(rawHex)) return null;
168
159
 
169
160
  try {
@@ -176,8 +167,8 @@ export function parseGroupRouteFromHexScope(scopeHex: string): BncrRoute | null
176
167
  }
177
168
  }
178
169
 
179
- export function parseDirectRouteFromHexScope(scopeHex: string): BncrRoute | null {
180
- const rawHex = asTargetString(scopeHex).trim();
170
+ function parseDirectRouteFromHexScope(scopeHex: string): BncrRoute | null {
171
+ const rawHex = asString(scopeHex).trim();
181
172
  if (!isLowerHex(rawHex)) return null;
182
173
 
183
174
  try {
@@ -197,15 +188,15 @@ export function parseStrictBncrSessionKey(input: string): {
197
188
  scopeHex: string;
198
189
  route: BncrRoute;
199
190
  } | null {
200
- const raw = asTargetString(input).trim();
191
+ const raw = asString(input).trim();
201
192
  if (!raw) return null;
202
193
 
203
194
  const m = raw.match(/^agent:([^:]+):bncr:(direct|group):(.+)$/);
204
195
  if (!m?.[1] || !m?.[2] || !m?.[3]) return null;
205
196
 
206
- const inputAgentId = asTargetString(m[1]).trim();
197
+ const inputAgentId = asString(m[1]).trim();
207
198
  const inputKind = m[2] as BncrSessionKind;
208
- const payload = asTargetString(m[3]).trim();
199
+ const payload = asString(m[3]).trim();
209
200
  let route: BncrRoute | null = null;
210
201
  let scopeHex = '';
211
202
 
@@ -243,13 +234,13 @@ export function parseStrictBncrSessionKey(input: string): {
243
234
  };
244
235
  }
245
236
 
246
- export function parseLegacySessionKey(input: string): {
237
+ function parseLegacySessionKey(input: string): {
247
238
  route: BncrRoute;
248
239
  inputKind: BncrSessionKind;
249
240
  inputAgentId?: string;
250
241
  source: 'legacy-direct' | 'legacy-bncr' | 'legacy-agent' | 'hex';
251
242
  } | null {
252
- const raw = asTargetString(input).trim();
243
+ const raw = asString(input).trim();
253
244
  if (!raw) return null;
254
245
 
255
246
  const directLegacy = raw.match(/^agent:([^:]+):bncr:direct:([0-9a-fA-F]+):0$/);
@@ -304,10 +295,10 @@ export function parseLegacySessionKey(input: string): {
304
295
  return null;
305
296
  }
306
297
 
307
- export function isLegacyNoiseRoute(route: BncrRoute): boolean {
308
- const platform = asTargetString(route.platform).trim().toLowerCase();
309
- const groupId = asTargetString(route.groupId).trim().toLowerCase();
310
- const userId = asTargetString(route.userId).trim().toLowerCase();
298
+ function isLegacyNoiseRoute(route: BncrRoute): boolean {
299
+ const platform = asString(route.platform).trim().toLowerCase();
300
+ const groupId = asString(route.groupId).trim().toLowerCase();
301
+ const userId = asString(route.userId).trim().toLowerCase();
311
302
 
312
303
  if (platform === 'agent' && groupId === 'main' && userId === 'bncr') return true;
313
304
  if (platform === 'bncr' && userId === '0' && isLowerHex(groupId)) return true;
@@ -326,7 +317,7 @@ export function parseRouteFromDisplayScope(scope: string): BncrRoute | null {
326
317
  }
327
318
 
328
319
  export function buildCanonicalBncrSessionKey(route: BncrRoute, canonicalAgentId: string): string {
329
- const agentId = asTargetString(canonicalAgentId).trim() || 'main';
320
+ const agentId = asString(canonicalAgentId).trim() || 'main';
330
321
  if (route.groupId !== '0') {
331
322
  return `agent:${agentId}:bncr:group:${groupScopeToHex(route)}`;
332
323
  }
@@ -338,7 +329,7 @@ export function normalizeStoredSessionKey(
338
329
  canonicalAgentId?: string | null,
339
330
  helpers?: { normalizeTaskKey?: (input: unknown) => string | null },
340
331
  ): { sessionKey: string; route: BncrRoute } | null {
341
- const raw = asTargetString(input).trim();
332
+ const raw = asString(input).trim();
342
333
  if (!raw) return null;
343
334
 
344
335
  let taskKey: string | null = null;
@@ -346,7 +337,7 @@ export function normalizeStoredSessionKey(
346
337
 
347
338
  const taskTagged = raw.match(/^(.*):task:([a-z0-9_-]{1,32})$/i);
348
339
  if (taskTagged) {
349
- base = asTargetString(taskTagged[1]).trim();
340
+ base = asString(taskTagged[1]).trim();
350
341
  taskKey = helpers?.normalizeTaskKey?.(taskTagged[2]) || null;
351
342
  }
352
343
 
@@ -370,7 +361,7 @@ export function normalizeStoredSessionKey(
370
361
  if (!route) return null;
371
362
  if (isLegacyNoiseRoute(route)) return null;
372
363
 
373
- const finalAgentId = asTargetString(canonicalAgentId).trim() || passthroughAgentId;
364
+ const finalAgentId = asString(canonicalAgentId).trim() || passthroughAgentId;
374
365
  if (!finalAgentId) return null;
375
366
 
376
367
  const finalSessionKey = buildCanonicalBncrSessionKey(route, finalAgentId);
@@ -385,7 +376,7 @@ export function normalizeInboundSessionKey(
385
376
  route: BncrRoute,
386
377
  canonicalAgentId: string,
387
378
  ): string | null {
388
- const raw = asTargetString(scope).trim();
379
+ const raw = asString(scope).trim();
389
380
  let finalRoute: BncrRoute | null = null;
390
381
 
391
382
  if (!raw) finalRoute = route;
@@ -399,15 +390,11 @@ export function normalizeInboundSessionKey(
399
390
  return buildCanonicalBncrSessionKey(finalRoute, canonicalAgentId);
400
391
  }
401
392
 
402
- export function buildFallbackSessionKey(route: BncrRoute, canonicalAgentId: string): string {
403
- return buildCanonicalBncrSessionKey(route, canonicalAgentId);
404
- }
405
-
406
393
  export function parseRouteLike(input: unknown): BncrRoute | null {
407
394
  const routeInput = input && typeof input === 'object' ? (input as RouteInputLike) : null;
408
- const platform = asTargetString(routeInput?.platform || '').trim();
409
- const groupId = asTargetString(routeInput?.groupId || '').trim();
410
- const userId = asTargetString(routeInput?.userId || '').trim();
395
+ const platform = asString(routeInput?.platform || '').trim();
396
+ const groupId = asString(routeInput?.groupId || '').trim();
397
+ const userId = asString(routeInput?.userId || '').trim();
411
398
  if (!platform || !groupId || !userId) return null;
412
399
  if (groupId !== '0' && userId !== '0') return null;
413
400
  return { platform, groupId, userId };
@@ -421,28 +408,23 @@ export type BncrExplicitTargetSource =
421
408
  | 'route-scope';
422
409
 
423
410
  export function formatDisplayScope(route: BncrRoute): string {
424
- const platform = asTargetString(route?.platform).trim();
425
- const groupId = asTargetString(route?.groupId || '0').trim() || '0';
426
- const userId = asTargetString(route?.userId || '0').trim() || '0';
411
+ const platform = asString(route?.platform).trim();
412
+ const groupId = asString(route?.groupId || '0').trim() || '0';
413
+ const userId = asString(route?.userId || '0').trim() || '0';
427
414
  if (groupId !== '0') return `Bncr:${platform}:${groupId}:0`;
428
415
  return `Bncr:${platform}:0:${userId}`;
429
416
  }
430
417
 
431
418
  export function formatHumanDisplayScope(route: BncrRoute): string {
432
- const platform = asTargetString(route?.platform).trim();
433
- const groupId = asTargetString(route?.groupId || '0').trim() || '0';
434
- const userId = asTargetString(route?.userId || '0').trim() || '0';
419
+ const platform = asString(route?.platform).trim();
420
+ const groupId = asString(route?.groupId || '0').trim() || '0';
421
+ const userId = asString(route?.userId || '0').trim() || '0';
435
422
  if (groupId !== '0') return `Bncr:${platform}:Group:${groupId}`;
436
423
  if (userId !== '0') return `Bncr:${platform}:User:${userId}`;
437
424
  return formatDisplayScope(route);
438
425
  }
439
426
 
440
- export function buildDisplayScopeCandidates(route: BncrRoute): string[] {
441
- const candidates = [formatDisplayScope(route)].filter(Boolean);
442
- return Array.from(new Set(candidates.map((x) => asTargetString(x).trim()).filter(Boolean)));
443
- }
444
-
445
- export function resolveExplicitTargetRoute(raw: string): {
427
+ function resolveExplicitTargetRoute(raw: string): {
446
428
  route: BncrRoute | null;
447
429
  source: BncrExplicitTargetSource | null;
448
430
  } {
@@ -491,10 +473,10 @@ export function resolveExplicitTargetRoute(raw: string): {
491
473
  }
492
474
 
493
475
  export function parseExplicitTarget(input: string, options?: { canonicalAgentId?: string | null }) {
494
- const raw = asTargetString(input).trim();
476
+ const raw = asString(input).trim();
495
477
  if (!raw) return null;
496
478
 
497
- const canonicalAgentId = asTargetString(options?.canonicalAgentId).trim() || undefined;
479
+ const canonicalAgentId = asString(options?.canonicalAgentId).trim() || undefined;
498
480
  const { route, source } = resolveExplicitTargetRoute(raw);
499
481
  if (!route || !source) return null;
500
482
 
@@ -510,8 +492,8 @@ export function parseExplicitTarget(input: string, options?: { canonicalAgentId?
510
492
  });
511
493
  }
512
494
 
513
- export function normalizeTaskKey(input: unknown): string | null {
514
- const raw = asTargetString(input).trim().toLowerCase();
495
+ function normalizeTaskKey(input: unknown): string | null {
496
+ const raw = asString(input).trim().toLowerCase();
515
497
  if (!raw) return null;
516
498
  const normalized = raw
517
499
  .replace(/[^a-z0-9_-]+/g, '-')
@@ -521,7 +503,7 @@ export function normalizeTaskKey(input: unknown): string | null {
521
503
  }
522
504
 
523
505
  export function extractInlineTaskKey(text: string): { taskKey: string | null; text: string } {
524
- const raw = asTargetString(text);
506
+ const raw = asString(text);
525
507
  if (!raw) return { taskKey: null, text: '' };
526
508
 
527
509
  const tagged = raw.match(
@@ -530,7 +512,7 @@ export function extractInlineTaskKey(text: string): { taskKey: string | null; te
530
512
  if (tagged) {
531
513
  return {
532
514
  taskKey: normalizeTaskKey(tagged[1]),
533
- text: asTargetString(tagged[2]),
515
+ text: asString(tagged[2]),
534
516
  };
535
517
  }
536
518
 
@@ -538,7 +520,7 @@ export function extractInlineTaskKey(text: string): { taskKey: string | null; te
538
520
  if (spaced) {
539
521
  return {
540
522
  taskKey: normalizeTaskKey(spaced[1]),
541
- text: asTargetString(spaced[2]),
523
+ text: asString(spaced[2]),
542
524
  };
543
525
  }
544
526
 
@@ -546,7 +528,7 @@ export function extractInlineTaskKey(text: string): { taskKey: string | null; te
546
528
  }
547
529
 
548
530
  export function withTaskSessionKey(sessionKey: string, taskKey?: string | null): string {
549
- const base = asTargetString(sessionKey).trim();
531
+ const base = asString(sessionKey).trim();
550
532
  const tk = normalizeTaskKey(taskKey);
551
533
  if (!base || !tk) return base;
552
534
  if (/:task:[a-z0-9_-]+(?:$|:)/i.test(base)) return base;
package/src/core/types.ts CHANGED
@@ -21,8 +21,6 @@ export type BncrGatewayCapabilityFlags = {
21
21
  inboundOnly: boolean;
22
22
  };
23
23
 
24
- export type FileRecvTransferStatus = 'init' | 'transferring' | 'completed' | 'aborted';
25
-
26
24
  type FileRecvTransferBase = {
27
25
  transferId: string;
28
26
  accountId: string;
@@ -105,21 +103,7 @@ export type OutboxEntry = {
105
103
  accountId: string;
106
104
  sessionKey: string;
107
105
  route: BncrRoute;
108
- payload: Record<string, unknown> & {
109
- _meta?: {
110
- kind?: 'message' | 'file-transfer';
111
- retryCount?: number;
112
- nextAttemptAt?: number;
113
- mediaUrl?: string;
114
- mediaLocalRoots?: string[];
115
- text?: string;
116
- asVoice?: boolean;
117
- audioAsVoice?: boolean;
118
- replyToId?: string;
119
- finalEvent?: string;
120
- [key: string]: unknown;
121
- };
122
- };
106
+ payload: Record<string, unknown>;
123
107
  createdAt: number;
124
108
  retryCount: number;
125
109
  nextAttemptAt: number;
@@ -1,15 +1,39 @@
1
- export function asSanitizedString(value: unknown, fallback = ''): string {
1
+ export function asString(value: unknown, fallback = ''): string {
2
2
  if (typeof value === 'string') return value;
3
3
  if (value == null) return fallback;
4
4
  return String(value);
5
5
  }
6
6
 
7
+ /** Alias: same as asString, named for call-sites that want a sanitization signal. */
8
+ export const asSanitizedString = asString;
9
+
7
10
  export function finiteNumberOr(value: unknown, fallback: number): number {
8
11
  const n = Number(value);
9
12
  return Number.isFinite(n) ? n : fallback;
10
13
  }
11
14
 
15
+ export function now(): number {
16
+ return Date.now();
17
+ }
18
+
19
+ export function asBoolean(value: unknown, fallback = false): boolean {
20
+ if (typeof value === 'boolean') return value;
21
+ if (typeof value === 'number') return value !== 0;
22
+ if (typeof value === 'string') {
23
+ const raw = value.trim().toLowerCase();
24
+ if (!raw) return fallback;
25
+ if (['true', '1', 'yes', 'y', 'on'].includes(raw)) return true;
26
+ if (['false', '0', 'no', 'n', 'off'].includes(raw)) return false;
27
+ }
28
+ return fallback;
29
+ }
30
+
31
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
32
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
33
+ }
34
+
12
35
  export function finiteNumberOrNull(value: unknown): number | null {
36
+ if (value === null || value === undefined) return null;
13
37
  const n = Number(value);
14
38
  return Number.isFinite(n) ? n : null;
15
39
  }
@@ -27,3 +51,16 @@ export function clampFiniteNumber(
27
51
  const finite = finiteNumberOr(value, fallback);
28
52
  return Math.max(min, Math.min(finite, max));
29
53
  }
54
+
55
+ export function asStringArray(v: unknown): string[] {
56
+ if (Array.isArray(v)) {
57
+ return v.map((item) => asString(item).trim()).filter(Boolean);
58
+ }
59
+ if (typeof v === 'string') {
60
+ return v
61
+ .split(',')
62
+ .map((item) => item.trim())
63
+ .filter(Boolean);
64
+ }
65
+ return [];
66
+ }
@@ -7,10 +7,7 @@ import {
7
7
  resolveBncrPinnedMainDmOwnerFromAllowlist,
8
8
  } from '../../openclaw/inbound-session-runtime.ts';
9
9
  import { dispatchOpenClawReplyWithBufferedBlockDispatcher } from '../../openclaw/reply-runtime.ts';
10
- import {
11
- type OpenClawResolvedAgentRoute,
12
- resolveOpenClawAgentRoute,
13
- } from '../../openclaw/routing-runtime.ts';
10
+ import { resolveOpenClawAgentRoute } from '../../openclaw/routing-runtime.ts';
14
11
  import type {
15
12
  BncrEnqueueFromReply,
16
13
  BncrInboundApi,
@@ -18,7 +15,7 @@ import type {
18
15
  BncrInboundLogger,
19
16
  BncrRememberSessionRoute,
20
17
  } from './contracts.ts';
21
- import type { ParsedInbound } from './dispatch-prep.ts';
18
+ import { assertResolvedAgentRoute, type ParsedInbound } from './dispatch-prep.ts';
22
19
  import { buildBncrInboundRecordUpdateLastRoute } from './last-route.ts';
23
20
  import {
24
21
  parseBncrNativeCommand,
@@ -50,25 +47,6 @@ import {
50
47
  } from './session-label.ts';
51
48
  import { createBncrSessionMetaTaskBarrier } from './session-meta-task.ts';
52
49
 
53
- function assertResolvedAgentRoute(resolvedRoute: OpenClawResolvedAgentRoute): {
54
- sessionKey: string;
55
- agentId: string;
56
- mainSessionKey?: string;
57
- } {
58
- const sessionKey =
59
- typeof resolvedRoute.sessionKey === 'string' ? resolvedRoute.sessionKey.trim() : '';
60
- const agentId = typeof resolvedRoute.agentId === 'string' ? resolvedRoute.agentId.trim() : '';
61
- if (!sessionKey) throw new Error('OpenClaw resolveAgentRoute returned empty sessionKey');
62
- if (!agentId) throw new Error('OpenClaw resolveAgentRoute returned empty agentId');
63
- return {
64
- sessionKey,
65
- agentId,
66
- ...(typeof resolvedRoute.mainSessionKey === 'string' && resolvedRoute.mainSessionKey.trim()
67
- ? { mainSessionKey: resolvedRoute.mainSessionKey }
68
- : {}),
69
- };
70
- }
71
-
72
50
  function buildBncrNativeCommandResolvedRoute(args: {
73
51
  api: BncrInboundApi;
74
52
  cfg: BncrInboundConfig;
@@ -95,7 +95,7 @@ const BNCR_GENERIC_MEDIA_TEXTS = new Set([
95
95
  '[文件]',
96
96
  ]);
97
97
 
98
- function assertResolvedAgentRoute(resolvedRoute: OpenClawResolvedAgentRoute): {
98
+ export function assertResolvedAgentRoute(resolvedRoute: OpenClawResolvedAgentRoute): {
99
99
  sessionKey: string;
100
100
  agentId: string;
101
101
  mainSessionKey?: string;
@@ -6,36 +6,7 @@ export type BncrGateResult = { allowed: true } | { allowed: false; reason: strin
6
6
  const REQUIRED_PROTOCOL_VERSION = 'scene-routing-v1';
7
7
  const REQUIRED_CAPABILITY = 'scene-routing-v1';
8
8
 
9
- function asString(v: unknown, fallback = ''): string {
10
- if (typeof v === 'string') return v;
11
- if (v == null) return fallback;
12
- return String(v);
13
- }
14
-
15
- function asBoolean(v: unknown, fallback = false): boolean {
16
- if (typeof v === 'boolean') return v;
17
- if (typeof v === 'number') return v !== 0;
18
- if (typeof v === 'string') {
19
- const raw = v.trim().toLowerCase();
20
- if (!raw) return fallback;
21
- if (['true', '1', 'yes', 'y', 'on'].includes(raw)) return true;
22
- if (['false', '0', 'no', 'n', 'off'].includes(raw)) return false;
23
- }
24
- return fallback;
25
- }
26
-
27
- function asCapabilities(v: unknown): string[] {
28
- if (Array.isArray(v)) {
29
- return v.map((item) => asString(item).trim()).filter(Boolean);
30
- }
31
- if (typeof v === 'string') {
32
- return v
33
- .split(',')
34
- .map((item) => item.trim())
35
- .filter(Boolean);
36
- }
37
- return [];
38
- }
9
+ import { asBoolean, asString, asStringArray } from '../../core/value-sanitize.ts';
39
10
 
40
11
  export async function checkBncrMessageGate(params: {
41
12
  parsed: BncrInboundParamsInput & {
@@ -57,7 +28,7 @@ export async function checkBncrMessageGate(params: {
57
28
  }
58
29
 
59
30
  const protocolVersion = asString(parsed?.protocolVersion || '').trim();
60
- const capabilities = asCapabilities(parsed?.capabilities);
31
+ const capabilities = asStringArray(parsed?.capabilities);
61
32
  if (
62
33
  protocolVersion !== REQUIRED_PROTOCOL_VERSION ||
63
34
  !capabilities.includes(REQUIRED_CAPABILITY)
@@ -1,4 +1,3 @@
1
- import path from 'node:path';
2
1
  import {
3
2
  createChannelHistoryWindow,
4
3
  DEFAULT_GROUP_HISTORY_LIMIT,
@@ -245,80 +244,6 @@ export function collectBncrHistoryMediaFromEntries(args: {
245
244
  }): HistoryMediaEntry[] {
246
245
  return args.entries.flatMap((entry) => (Array.isArray(entry.media) ? entry.media : []));
247
246
  }
248
-
249
- function toBncrPromptMediaPath(value: string | undefined): string | undefined {
250
- const normalized = String(value || '').trim();
251
- if (!normalized) return undefined;
252
- const slashNormalized = normalized.replace(/\\/g, '/');
253
- if (!slashNormalized.includes('/media/inbound/')) return undefined;
254
- const base = path.posix.basename(slashNormalized);
255
- if (!base || base === '.' || base === '..') return undefined;
256
- return `media://inbound/${encodeURIComponent(base)}`;
257
- }
258
-
259
- function buildHistoryWindowMediaSummary(
260
- media: HistoryMediaEntry[] | undefined,
261
- ): string | undefined {
262
- if (!Array.isArray(media) || media.length === 0) return undefined;
263
- const kinds = media
264
- .map((item) =>
265
- String(item?.kind || '')
266
- .trim()
267
- .toLowerCase(),
268
- )
269
- .filter(Boolean);
270
- const uniformKind = kinds[0] && kinds.every((item) => item === kinds[0]) ? kinds[0] : 'document';
271
- const count = media.length;
272
- if (uniformKind === 'image')
273
- return count === 1 ? '<media:image>' : `<media:image> (${count} images)`;
274
- if (uniformKind === 'video')
275
- return count === 1 ? '<media:video>' : `<media:video> (${count} videos)`;
276
- if (uniformKind === 'audio') {
277
- return count === 1 ? '<media:audio>' : `<media:audio> (${count} audio attachments)`;
278
- }
279
- return count === 1 ? '<media:document>' : `<media:document> (${count} attachments)`;
280
- }
281
-
282
- function buildHistoryWindowMedias(media: HistoryMediaEntry[] | undefined) {
283
- if (!Array.isArray(media) || media.length === 0) return [];
284
- return media.flatMap((item) => {
285
- const promptPath = toBncrPromptMediaPath(item?.path);
286
- const payload = {
287
- ...(promptPath ? { path: promptPath } : {}),
288
- ...(item?.contentType ? { contentType: item.contentType } : {}),
289
- ...(item?.kind ? { kind: item.kind } : {}),
290
- ...(item?.messageId ? { messageId: item.messageId } : {}),
291
- };
292
- return Object.keys(payload).length > 0 ? [payload] : [];
293
- });
294
- }
295
-
296
- export function buildBncrHistoryWindowContextFromEntries(args: {
297
- entries: readonly BncrHistoryEntry[];
298
- channelId: string;
299
- }) {
300
- if (args.entries.length === 0) return undefined;
301
- const messages = args.entries.map((entry) => ({
302
- ...(entry.messageId ? { messageId: entry.messageId } : {}),
303
- sender: entry.sender,
304
- senderId: entry.senderId,
305
- ...(typeof entry.timestamp === 'number' ? { timestampMs: entry.timestamp } : {}),
306
- body: entry.body,
307
- mediaSummary: buildHistoryWindowMediaSummary(entry.media),
308
- medias: buildHistoryWindowMedias(entry.media),
309
- }));
310
- return {
311
- label: 'Bncr history window',
312
- source: args.channelId,
313
- type: 'bncr.history_window',
314
- payload: {
315
- relation: 'before_current_message',
316
- order: 'chronological',
317
- messages,
318
- },
319
- };
320
- }
321
-
322
247
  export function clearBncrPendingGroupHistory(args: {
323
248
  historyMap: BncrGroupHistoryMap;
324
249
  parsed: ParsedInbound;
@@ -2,39 +2,9 @@ import { createHash } from 'node:crypto';
2
2
  import { normalizeAccountId } from '../../core/accounts.ts';
3
3
  import { extractInlineTaskKey } from '../../core/targets.ts';
4
4
  import type { BncrRoute } from '../../core/types.ts';
5
+ import { asBoolean, asString, asStringArray } from '../../core/value-sanitize.ts';
5
6
  import type { BncrInboundMediaItem, BncrInboundParamsInput } from './contracts.ts';
6
7
 
7
- function asString(v: unknown, fallback = ''): string {
8
- if (typeof v === 'string') return v;
9
- if (v == null) return fallback;
10
- return String(v);
11
- }
12
-
13
- function asBoolean(v: unknown, fallback = false): boolean {
14
- if (typeof v === 'boolean') return v;
15
- if (typeof v === 'number') return v !== 0;
16
- if (typeof v === 'string') {
17
- const raw = v.trim().toLowerCase();
18
- if (!raw) return fallback;
19
- if (['true', '1', 'yes', 'y', 'on'].includes(raw)) return true;
20
- if (['false', '0', 'no', 'n', 'off'].includes(raw)) return false;
21
- }
22
- return fallback;
23
- }
24
-
25
- function asStringArray(v: unknown): string[] {
26
- if (Array.isArray(v)) {
27
- return v.map((item) => asString(item).trim()).filter(Boolean);
28
- }
29
- if (typeof v === 'string') {
30
- return v
31
- .split(',')
32
- .map((item) => item.trim())
33
- .filter(Boolean);
34
- }
35
- return [];
36
- }
37
-
38
8
  function asInboundMediaItems(params: BncrInboundParamsInput): BncrInboundMediaItem[] {
39
9
  const normalized: BncrInboundMediaItem[] = [];
40
10
  const rawList = Array.isArray(params?.mediaList) ? params.mediaList : [];
@@ -88,7 +58,7 @@ function asInboundMediaItems(params: BncrInboundParamsInput): BncrInboundMediaIt
88
58
  }));
89
59
  }
90
60
 
91
- export function inboundDedupKey(params: {
61
+ function inboundDedupKey(params: {
92
62
  accountId: string;
93
63
  platform: string;
94
64
  groupId: string;