@xmoxmo/bncr 0.5.5 → 0.5.7

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 (78) 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 -6
  5. package/scripts/selfcheck.mjs +0 -7
  6. package/src/channel.ts +114 -37
  7. package/src/core/accounts.ts +1 -6
  8. package/src/core/connection-reachability.ts +1 -5
  9. package/src/core/dead-letter-diagnostics.ts +4 -10
  10. package/src/core/outbox-entry-builders.ts +38 -15
  11. package/src/core/outbox-file-transfer-failure.ts +5 -0
  12. package/src/core/outbox-file-transfer-guards.ts +6 -0
  13. package/src/core/outbox-file-transfer-prep.ts +8 -5
  14. package/src/core/outbox-file-transfer-success.ts +5 -0
  15. package/src/core/outbox-queue.ts +1 -7
  16. package/src/core/outbox-summary.ts +15 -27
  17. package/src/core/outbox-text-push-prep.ts +1 -36
  18. package/src/core/permissions.ts +1 -9
  19. package/src/core/persisted-outbox-entry.ts +54 -11
  20. package/src/core/policy.ts +1 -11
  21. package/src/core/register-trace.ts +2 -5
  22. package/src/core/status-meta.ts +1 -9
  23. package/src/core/status.ts +1 -21
  24. package/src/core/targets.ts +52 -70
  25. package/src/core/types.ts +1 -17
  26. package/src/core/value-sanitize.ts +38 -1
  27. package/src/messaging/inbound/commands.ts +2 -24
  28. package/src/messaging/inbound/dispatch-prep.ts +1 -1
  29. package/src/messaging/inbound/gate.ts +2 -31
  30. package/src/messaging/inbound/group-history.ts +0 -75
  31. package/src/messaging/inbound/native-command.ts +7 -3
  32. package/src/messaging/inbound/parse.ts +1 -31
  33. package/src/messaging/inbound/scene-admin.ts +181 -5
  34. package/src/messaging/outbound/diagnostics.ts +1 -6
  35. package/src/messaging/outbound/durable-queue-adapter.ts +17 -11
  36. package/src/messaging/outbound/marker-parser.ts +188 -0
  37. package/src/messaging/outbound/media-dedupe.ts +2 -6
  38. package/src/messaging/outbound/media.ts +6 -20
  39. package/src/messaging/outbound/normalize-outbound-send.ts +327 -0
  40. package/src/messaging/outbound/queue-selectors.ts +1 -5
  41. package/src/messaging/outbound/reply-enqueue-media.ts +4 -0
  42. package/src/messaging/outbound/reply-enqueue.ts +130 -57
  43. package/src/messaging/outbound/session-route.ts +6 -11
  44. package/src/messaging/outbound/target-resolver.ts +1 -6
  45. package/src/openclaw/routing-runtime.ts +0 -11
  46. package/src/openclaw/sdk-helpers.ts +0 -2
  47. package/src/plugin/bridge-file-transfer-push-facade.ts +8 -6
  48. package/src/plugin/bridge-media-facade.ts +1 -1
  49. package/src/plugin/channel-components.ts +0 -13
  50. package/src/plugin/channel-plugin-surface-group.ts +57 -40
  51. package/src/plugin/channel-runtime-builders-delivery.ts +3 -0
  52. package/src/plugin/channel-runtime-types.ts +10 -24
  53. package/src/plugin/channel-send.ts +241 -154
  54. package/src/plugin/channel-utils.ts +8 -29
  55. package/src/plugin/connection-handlers-helpers.ts +0 -15
  56. package/src/plugin/connection-handlers.ts +6 -5
  57. package/src/plugin/file-inbound-abort.ts +5 -0
  58. package/src/plugin/file-inbound-chunk.ts +5 -0
  59. package/src/plugin/file-inbound-complete.ts +5 -0
  60. package/src/plugin/file-transfer-orchestrator.ts +105 -3
  61. package/src/plugin/file-transfer-setup.ts +1 -1
  62. package/src/plugin/media-dedupe-runtime.ts +1 -6
  63. package/src/plugin/media-orchestrators-runtime-group.ts +2 -0
  64. package/src/plugin/message-send.ts +7 -8
  65. package/src/plugin/outbound.ts +6 -22
  66. package/src/plugin/outbox-drain-loop.ts +2 -2
  67. package/src/plugin/outbox-file-push-flow.ts +7 -7
  68. package/src/plugin/outbox-text-push-flow.ts +1 -1
  69. package/src/plugin/target-runtime.ts +1 -6
  70. package/src/runtime/outbound-ack-timeout.ts +1 -8
  71. package/src/runtime/outbox-transitions.ts +1 -4
  72. package/src/runtime/status-worker.ts +1 -9
  73. package/src/messaging/outbound/build-send-action.ts +0 -130
  74. package/src/messaging/outbound/durable-message-adapter.ts +0 -121
  75. package/src/messaging/outbound/send-params.ts +0 -81
  76. package/src/messaging/outbound/send.ts +0 -121
  77. package/src/openclaw/ingress-runtime.ts +0 -25
  78. package/src/plugin/channel-send-runtime-group.ts +0 -37
@@ -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 : [];
@@ -13,16 +13,22 @@ export type BncrSceneAdminCommand =
13
13
  | { kind: 'bind'; sceneKey?: string; agentId: string }
14
14
  | { kind: 'mode-help' }
15
15
  | { kind: 'mode-get'; sceneKey?: string }
16
- | { kind: 'mode'; sceneKey: string; mode: BncrGroupReplyMode }
16
+ | { kind: 'mode'; sceneKey: string; mode: string }
17
17
  | { kind: 'list'; scope: 'pending' | 'scenes'; filters?: string[] }
18
18
  | { kind: 'history-limit-get'; sceneKey?: string }
19
- | { kind: 'history-limit-set'; sceneKey: string; limit: number }
19
+ | { kind: 'history-limit-set'; sceneKey: string; limit: number | 'clear' }
20
20
  | { kind: 'history-force-get'; sceneKey?: string }
21
- | { kind: 'history-force-set'; sceneKey: string; enabled: boolean }
22
- | { kind: 'history-help' };
21
+ | { kind: 'history-force-set'; sceneKey: string; enabled: boolean | 'clear' }
22
+ | { kind: 'history-help' }
23
+ | { kind: 'download-media-get'; sceneKey?: string }
24
+ | { kind: 'download-media-set'; sceneKey: string; enabled: boolean | undefined }
25
+ | { kind: 'download-media-global-get' }
26
+ | { kind: 'download-media-global-set'; enabled: boolean };
23
27
 
24
28
  const GROUP_REPLY_MODES = new Set<BncrGroupReplyMode>(['admin', 'mention', 'hybrid', 'all']);
25
29
 
30
+ const GLOBAL_SCENE_KEY = '__global__';
31
+
26
32
  const MODE_HELP_TEXT = [
27
33
  '💬 Bncr Group Reply Mode Configuration',
28
34
  '',
@@ -122,6 +128,13 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
122
128
  return { matched: true, valid: true, command: { kind: 'mode-get' } };
123
129
  }
124
130
  if (args.length === 1) {
131
+ if (args[0] === 'clear') {
132
+ return {
133
+ matched: true,
134
+ valid: true,
135
+ command: { kind: 'mode', sceneKey: '', mode: 'clear' },
136
+ };
137
+ }
125
138
  if (args[0] === 'help') {
126
139
  return {
127
140
  matched: true,
@@ -142,6 +155,13 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
142
155
  command: { kind: 'mode-get', sceneKey: args[0] },
143
156
  };
144
157
  }
158
+ if (args[0] === 'clear' && args[1]) {
159
+ return {
160
+ matched: true,
161
+ valid: true,
162
+ command: { kind: 'mode', sceneKey: args[1], mode: 'clear' },
163
+ };
164
+ }
145
165
  if (GROUP_REPLY_MODES.has(args[0] as BncrGroupReplyMode) && args[1]) {
146
166
  return {
147
167
  matched: true,
@@ -181,6 +201,13 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
181
201
  return { matched: true, valid: true, command: { kind: 'history-limit-get' } };
182
202
  }
183
203
  if (args.length === 1) {
204
+ if (args[0] === 'clear') {
205
+ return {
206
+ matched: true,
207
+ valid: true,
208
+ command: { kind: 'history-limit-set', sceneKey: '', limit: 'clear' },
209
+ };
210
+ }
184
211
  const num = parseInt(args[0], 10);
185
212
  if (Number.isFinite(num)) {
186
213
  return {
@@ -196,6 +223,13 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
196
223
  };
197
224
  }
198
225
  if (args.length === 2) {
226
+ if (args[0] === 'clear') {
227
+ return {
228
+ matched: true,
229
+ valid: true,
230
+ command: { kind: 'history-limit-set', sceneKey: args[1], limit: 'clear' },
231
+ };
232
+ }
199
233
  const num = parseInt(args[0], 10);
200
234
  if (Number.isFinite(num)) {
201
235
  return {
@@ -214,6 +248,20 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
214
248
  if (args.length === 0) {
215
249
  return { matched: true, valid: true, command: { kind: 'history-force-get' } };
216
250
  }
251
+ if (args[0] === 'clear') {
252
+ if (args.length === 1) {
253
+ return {
254
+ matched: true,
255
+ valid: true,
256
+ command: { kind: 'history-force-set', sceneKey: '', enabled: 'clear' },
257
+ };
258
+ }
259
+ return {
260
+ matched: true,
261
+ valid: true,
262
+ command: { kind: 'history-force-set', sceneKey: args[1], enabled: 'clear' },
263
+ };
264
+ }
217
265
  if (args[0] === 'on' || args[0] === 'off') {
218
266
  if (args.length === 1) {
219
267
  return {
@@ -233,6 +281,50 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
233
281
  valid: true,
234
282
  command: { kind: 'history-force-get', sceneKey: args[0] },
235
283
  };
284
+ case 'download-media':
285
+ if (args.length === 0) {
286
+ return { matched: true, valid: true, command: { kind: 'download-media-get' } };
287
+ }
288
+ if (args[0] === 'default') {
289
+ if (args[1] === 'on' || args[1] === 'off') {
290
+ return {
291
+ matched: true,
292
+ valid: true,
293
+ command: { kind: 'download-media-global-set', enabled: args[1] === 'on' },
294
+ };
295
+ }
296
+ return { matched: true, valid: true, command: { kind: 'download-media-global-get' } };
297
+ }
298
+ if (args[0] === 'clear') {
299
+ return {
300
+ matched: true,
301
+ valid: true,
302
+ command: {
303
+ kind: 'download-media-set',
304
+ sceneKey: args[1] || '',
305
+ enabled: undefined,
306
+ },
307
+ };
308
+ }
309
+ if (args[0] === 'on' || args[0] === 'off') {
310
+ if (args.length === 1) {
311
+ return {
312
+ matched: true,
313
+ valid: true,
314
+ command: { kind: 'download-media-set', sceneKey: '', enabled: args[0] === 'on' },
315
+ };
316
+ }
317
+ return {
318
+ matched: true,
319
+ valid: true,
320
+ command: { kind: 'download-media-set', sceneKey: args[1], enabled: args[0] === 'on' },
321
+ };
322
+ }
323
+ return {
324
+ matched: true,
325
+ valid: true,
326
+ command: { kind: 'download-media-get', sceneKey: args[0] },
327
+ };
236
328
  case 'history-help':
237
329
  return { matched: true, valid: true, command: { kind: 'history-help' } };
238
330
  default:
@@ -399,6 +491,11 @@ export function executeSceneAdminCommand(args: {
399
491
  }
400
492
  const sExisting = sceneRegistry.get(sSceneKey);
401
493
  if (!sExisting) return { ok: false, text: `Scene not found: ${sSceneKey}` };
494
+ if (command.limit === 'clear') {
495
+ const { historyLimit: _, ...rest } = sExisting;
496
+ sceneRegistry.set(sSceneKey, { ...rest, lastSeenAt: now() });
497
+ return { ok: true, text: `Cleared ${sSceneKey} history limit. Will use default (50).` };
498
+ }
402
499
  const rawLimit = command.limit;
403
500
  if (!Number.isFinite(rawLimit) || Number.isNaN(rawLimit)) {
404
501
  return { ok: false, text: 'Invalid number.' };
@@ -443,6 +540,11 @@ export function executeSceneAdminCommand(args: {
443
540
  }
444
541
  const sExisting = sceneRegistry.get(sSceneKey);
445
542
  if (!sExisting) return { ok: false, text: `Scene not found: ${sSceneKey}` };
543
+ if (command.enabled === 'clear') {
544
+ const { historyForce: _, ...rest } = sExisting;
545
+ sceneRegistry.set(sSceneKey, { ...rest, lastSeenAt: now() });
546
+ return { ok: true, text: `Cleared ${sSceneKey} history auto flush. Will use default (on).` };
547
+ }
446
548
  sceneRegistry.set(sSceneKey, {
447
549
  ...sExisting,
448
550
  historyForce: command.enabled,
@@ -454,6 +556,75 @@ export function executeSceneAdminCommand(args: {
454
556
  };
455
557
  }
456
558
 
559
+ if (command.kind === 'download-media-global-get') {
560
+ const g = sceneRegistry.get(GLOBAL_SCENE_KEY);
561
+ const enabled = g?.downloadMedia === true;
562
+ return { ok: true, text: `Global default download remote media is ${enabled ? 'on' : 'off'}.` };
563
+ }
564
+
565
+ if (command.kind === 'download-media-global-set') {
566
+ const g = sceneRegistry.get(GLOBAL_SCENE_KEY);
567
+ sceneRegistry.set(GLOBAL_SCENE_KEY, {
568
+ sceneKey: GLOBAL_SCENE_KEY,
569
+ kind: 'group',
570
+ status: 'allowed',
571
+ ...(g || { platform: 'bncr' }),
572
+ downloadMedia: command.enabled,
573
+ lastSeenAt: now(),
574
+ });
575
+ return {
576
+ ok: true,
577
+ text: `Global default download remote media set to ${command.enabled ? 'on' : 'off'}.`,
578
+ };
579
+ }
580
+
581
+ if (command.kind === 'download-media-get') {
582
+ const qSceneKey = command.sceneKey || resolveCurrentGroupSceneKey(parsed);
583
+ if (!qSceneKey) {
584
+ return {
585
+ ok: true,
586
+ text: `Global default download remote media is ${sceneRegistry.get(GLOBAL_SCENE_KEY)?.downloadMedia === true ? 'on' : 'off'}.`,
587
+ };
588
+ }
589
+ const s = sceneRegistry.get(qSceneKey);
590
+ if (!s) return { ok: true, text: 'Default download remote media is off.' };
591
+ const enabled = s.downloadMedia === true;
592
+ return {
593
+ ok: true,
594
+ text: `Current ${qSceneKey} download remote media is ${enabled ? 'on' : 'off'}.`,
595
+ };
596
+ }
597
+
598
+ if (command.kind === 'download-media-set') {
599
+ const sSceneKey = command.sceneKey || resolveCurrentGroupSceneKey(parsed);
600
+ if (!sSceneKey) {
601
+ return { ok: false, text: 'Current group shortcut only works inside a group chat.' };
602
+ }
603
+ const sExisting = sceneRegistry.get(sSceneKey);
604
+ if (command.enabled === undefined) {
605
+ // Clear per-group setting
606
+ if (sExisting) {
607
+ const { downloadMedia: _, ...rest } = sExisting;
608
+ sceneRegistry.set(sSceneKey, { ...rest, lastSeenAt: now() });
609
+ return {
610
+ ok: true,
611
+ text: `Cleared ${sSceneKey} download remote media. Will use global default.`,
612
+ };
613
+ }
614
+ return { ok: true, text: `No per-group config to clear for ${sSceneKey}.` };
615
+ }
616
+ if (!sExisting) return { ok: false, text: `Scene not found: ${sSceneKey}` };
617
+ sceneRegistry.set(sSceneKey, {
618
+ ...sExisting,
619
+ downloadMedia: command.enabled,
620
+ lastSeenAt: now(),
621
+ });
622
+ return {
623
+ ok: true,
624
+ text: `Set ${sSceneKey} download remote media to ${command.enabled ? 'on' : 'off'}.`,
625
+ };
626
+ }
627
+
457
628
  const sceneKey = command.sceneKey || resolveCurrentGroupSceneKey(parsed);
458
629
  if (!sceneKey) {
459
630
  return { ok: false, text: 'Current group shortcut only works inside a group chat.' };
@@ -482,9 +653,14 @@ export function executeSceneAdminCommand(args: {
482
653
  if (existing.kind !== 'group') {
483
654
  return { ok: false, text: `Scene ${sceneKey} is not a group scene.` };
484
655
  }
656
+ if (command.mode === 'clear') {
657
+ const { groupReplyMode: _, ...rest } = existing;
658
+ sceneRegistry.set(sceneKey, { ...rest, lastSeenAt: now() });
659
+ return { ok: true, text: `Cleared ${sceneKey} reply mode. Will use default (admin).` };
660
+ }
485
661
  sceneRegistry.set(sceneKey, {
486
662
  ...existing,
487
- groupReplyMode: command.mode,
663
+ groupReplyMode: command.mode as BncrGroupReplyMode,
488
664
  lastSeenAt: now(),
489
665
  });
490
666
  return { ok: true, text: `Set ${sceneKey} reply mode to ${command.mode}.` };
@@ -5,6 +5,7 @@ import type {
5
5
  BncrOutboxQueueDiagnostics,
6
6
  OutboxEntry,
7
7
  } from '../../core/types.ts';
8
+ import { finiteNumberOrNull } from '../../core/value-sanitize.ts';
8
9
 
9
10
  export {
10
11
  buildFlushDebugInfo,
@@ -42,12 +43,6 @@ type OutboxIncidentSummaryInput = {
42
43
  nowMs?: number;
43
44
  };
44
45
 
45
- function finiteNumberOrNull(value: unknown): number | null {
46
- if (value == null) return null;
47
- const n = Number(value);
48
- return Number.isFinite(n) ? n : null;
49
- }
50
-
51
46
  function positiveAgeMs(nowMs: number, at: unknown): number | null {
52
47
  const n = finiteNumberOrNull(at);
53
48
  if (n === null || n < 0) return null;
@@ -8,7 +8,7 @@ export type BncrDurableQueuedReceipt = {
8
8
  platformMessageId: string;
9
9
  kind: 'text' | 'media' | 'voice' | 'unknown';
10
10
  index: number;
11
- threadId?: string;
11
+ threadId?: string | number | null;
12
12
  replyToId?: string;
13
13
  raw: {
14
14
  channel: 'bncr';
@@ -20,7 +20,7 @@ export type BncrDurableQueuedReceipt = {
20
20
  meta: BncrDurableQueuedReceiptMeta;
21
21
  };
22
22
  }>;
23
- threadId?: string;
23
+ threadId?: string | number | null;
24
24
  replyToId?: string;
25
25
  sentAt: number;
26
26
  raw: Array<{
@@ -66,8 +66,12 @@ export type BncrDurableQueuedResult = {
66
66
  };
67
67
 
68
68
  type OutboxPayload = OutboxEntry['payload'];
69
- type OutboxMeta = NonNullable<OutboxPayload['_meta']>;
70
- type OutboxMessagePayload = { type?: unknown };
69
+ type OutboxMessagePayload = {
70
+ type?: unknown;
71
+ transferMode?: string;
72
+ asVoice?: boolean;
73
+ audioAsVoice?: boolean;
74
+ };
71
75
 
72
76
  function getOutboxPayload(entry: OutboxEntry): OutboxPayload {
73
77
  return entry.payload;
@@ -76,7 +80,7 @@ function getOutboxPayload(entry: OutboxEntry): OutboxPayload {
76
80
  export function buildBncrDurableQueuedResult(args: {
77
81
  entry: OutboxEntry;
78
82
  index?: number;
79
- threadId?: string;
83
+ threadId?: string | number | null;
80
84
  replyToId?: string;
81
85
  sentAt?: number;
82
86
  }): BncrDurableQueuedResult {
@@ -145,20 +149,22 @@ function extractPayloadType(entry: OutboxEntry): string | undefined {
145
149
 
146
150
  function extractReplyToId(entry: OutboxEntry): string | undefined {
147
151
  const payload = getOutboxPayload(entry);
148
- const metaReply = (payload?._meta as OutboxMeta | undefined)?.replyToId;
149
- const replyToId = payload?.replyToId ?? metaReply;
152
+ const message =
153
+ payload?.message && typeof payload.message === 'object' && !Array.isArray(payload.message)
154
+ ? (payload.message as Record<string, unknown>)
155
+ : null;
156
+ const replyToId = payload?.replyToId ?? (message?.replyToId as string | undefined);
150
157
  return typeof replyToId === 'string' ? replyToId : undefined;
151
158
  }
152
159
 
153
160
  function inferReceiptKind(entry: OutboxEntry): 'text' | 'media' | 'voice' | 'unknown' {
154
161
  const payload = getOutboxPayload(entry);
155
- const meta = payload?._meta as OutboxMeta | undefined;
156
162
  const message =
157
- payload.message && typeof payload.message === 'object'
163
+ payload?.message && typeof payload.message === 'object'
158
164
  ? (payload.message as OutboxMessagePayload)
159
165
  : null;
160
- if (meta?.kind === 'file-transfer') {
161
- if (meta.asVoice === true || meta.audioAsVoice === true) return 'voice';
166
+ if (message?.transferMode === 'media') {
167
+ if (message?.asVoice === true || message?.audioAsVoice === true) return 'voice';
162
168
  return 'media';
163
169
  }
164
170
  if (message?.type === 'text') return 'text';
@@ -0,0 +1,188 @@
1
+ /**
2
+ * [BncrParam:JSON] marker parser.
3
+ *
4
+ * Extracts embedded JSON control parameters from agent text, removes the
5
+ * marker from the visible message, and returns the parsed key-value pairs.
6
+ *
7
+ * Format: [BncrParam:{"key":"val","asVoice":true}]
8
+ *
9
+ * - Complete marker whose JSON parses successfully → stripped, params merged.
10
+ * - Complete marker whose JSON fails to parse → left in text (not stripped)
11
+ * so the user can see and fix the bad payload.
12
+ * - Incomplete marker (no closing ]) → left in text, does NOT block later
13
+ * complete markers.
14
+ * - Multiple valid markers → all stripped, params merge (later same-key wins).
15
+ */
16
+
17
+ const PREFIX = '[BncrParam:';
18
+
19
+ /**
20
+ * Locate the closing `]` of a complete marker starting at `prefixIdx`.
21
+ * Returns the index of the closing `]`, or -1 if incomplete.
22
+ */
23
+ function findMarkerEnd(text: string, prefixIdx: number): number {
24
+ const start = prefixIdx + PREFIX.length;
25
+ if (start >= text.length) return -1;
26
+
27
+ // Scan character-by-character tracking brace/bracket depth so that
28
+ // ] inside JSON values (e.g. CDATA / lyric tags) is not mistaken for
29
+ // the closing marker bracket.
30
+ let depth = 0;
31
+ let inString = false;
32
+ let esc = false;
33
+ let started = false;
34
+
35
+ for (let i = start; i < text.length; i++) {
36
+ const ch = text[i];
37
+
38
+ if (esc) {
39
+ esc = false;
40
+ continue;
41
+ }
42
+ if (ch === '\\' && inString) {
43
+ esc = true;
44
+ continue;
45
+ }
46
+ if (ch === '"') {
47
+ inString = !inString;
48
+ continue;
49
+ }
50
+
51
+ if (!inString) {
52
+ if (!started) {
53
+ if (ch === '{' || ch === '[') {
54
+ started = true;
55
+ depth = 1;
56
+ }
57
+ continue;
58
+ }
59
+
60
+ if (ch === '{' || ch === '[') {
61
+ depth++;
62
+ } else if (ch === '}' || ch === ']') {
63
+ depth--;
64
+ if (depth === 0) {
65
+ // JSON content complete - expect ] to close the marker
66
+ if (i + 1 < text.length && text[i + 1] === ']') {
67
+ return i + 1;
68
+ }
69
+ return -1;
70
+ }
71
+ }
72
+ }
73
+ }
74
+
75
+ return -1;
76
+ }
77
+
78
+ export function parseBncrMarker(text: string): {
79
+ cleanText: string;
80
+ params: Record<string, unknown>;
81
+ } {
82
+ const params: Record<string, unknown> = {};
83
+ let result = text;
84
+ let searchFrom = 0;
85
+
86
+ while (true) {
87
+ const idx = result.indexOf(PREFIX, searchFrom);
88
+ if (idx === -1) break;
89
+
90
+ const end = findMarkerEnd(result, idx);
91
+ if (end === -1) {
92
+ // Incomplete marker (missing ]): leave in place,
93
+ // keep looking after this PREFIX so later valid markers are not blocked.
94
+ searchFrom = idx + PREFIX.length;
95
+ continue;
96
+ }
97
+
98
+ const rawJson = result.slice(idx + PREFIX.length, end);
99
+ let parsedOk = false;
100
+ try {
101
+ const parsed = JSON.parse(rawJson);
102
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
103
+ Object.assign(params, parsed as Record<string, unknown>);
104
+ parsedOk = true;
105
+ }
106
+ } catch {
107
+ // JSON parse failed — do NOT strip marker so the user can debug it
108
+ }
109
+
110
+ if (parsedOk) {
111
+ // Strip the complete valid marker
112
+ result = result.slice(0, idx) + result.slice(end + 1);
113
+ searchFrom = idx;
114
+ } else {
115
+ // Leave the invalid marker in place, continue after it
116
+ searchFrom = end + 1;
117
+ }
118
+ }
119
+
120
+ // Only trim whitespace when markers were actually stripped (result changed).
121
+ // Without markers the original text is returned as-is for maximum fidelity.
122
+ const cleanText = result !== text ? result.replace(/ {2,}/g, ' ').trim() : result;
123
+ return { cleanText, params };
124
+ }
125
+
126
+ /**
127
+ * Media types that the pipeline internally routes as media sends.
128
+ * Only these types are consumed (stripped) from extra.
129
+ * Non-media types like "appmsg" remain in extra for downstream passthrough.
130
+ */
131
+ const MEDIA_CONSUMPTION_TYPES = new Set(['file', 'image', 'video', 'audio', 'voice']);
132
+
133
+ /**
134
+ * Known fields that the bncr plugin unconditionally consumes during outbound
135
+ * processing. These are stripped from extra at entry and set as independent
136
+ * pipeline params. `type` is NOT in this set — it is consumed conditionally
137
+ * based on whether its value is a recognized media type.
138
+ */
139
+ const CONSUMPTION_FIELDS_NO_TYPE = new Set([
140
+ 'ismedia',
141
+ 'asVoice',
142
+ 'audioAsVoice',
143
+ 'kind',
144
+ 'replyToId',
145
+ 'downloadMedia',
146
+ ]);
147
+
148
+ export function extractConsumptionFields(extra: Record<string, unknown> | undefined): {
149
+ consumed: Partial<{
150
+ asVoice: boolean;
151
+ audioAsVoice: boolean;
152
+ type: string;
153
+ kind: string;
154
+ replyToId: string;
155
+ downloadMedia: boolean;
156
+ ismedia: boolean;
157
+ }>;
158
+ remaining: Record<string, unknown>;
159
+ } {
160
+ const consumed: Record<string, unknown> = {};
161
+ const remaining: Record<string, unknown> = {};
162
+
163
+ if (!extra) return { consumed: {}, remaining: {} };
164
+
165
+ for (const [key, value] of Object.entries(extra)) {
166
+ // type is only consumed when its value is a recognized media type
167
+ // (file/image/video/audio/voice). Non-media types like "appmsg"
168
+ // stay in remaining for downstream passthrough.
169
+ if (key === 'type' && typeof value === 'string') {
170
+ if (MEDIA_CONSUMPTION_TYPES.has(value.trim().toLowerCase())) {
171
+ consumed[key] = value;
172
+ } else {
173
+ remaining[key] = value;
174
+ }
175
+ continue;
176
+ }
177
+ if (CONSUMPTION_FIELDS_NO_TYPE.has(key)) {
178
+ consumed[key] = value;
179
+ } else {
180
+ remaining[key] = value;
181
+ }
182
+ }
183
+
184
+ return {
185
+ consumed: consumed as Record<string, unknown>,
186
+ remaining,
187
+ };
188
+ }
@@ -1,3 +1,5 @@
1
+ import { asString } from '../../core/value-sanitize.ts';
2
+
1
3
  export type MediaDedupeCacheEntry = {
2
4
  mediaUrl: string;
3
5
  text: string;
@@ -5,12 +7,6 @@ export type MediaDedupeCacheEntry = {
5
7
  createdAt: number;
6
8
  };
7
9
 
8
- function asString(v: unknown, fallback = ''): string {
9
- if (typeof v === 'string') return v;
10
- if (v == null) return fallback;
11
- return String(v);
12
- }
13
-
14
10
  export function normalizeReplyToId(value: unknown): string {
15
11
  return asString(value || '').trim();
16
12
  }
@@ -1,18 +1,8 @@
1
+ import { asString } from '../../core/value-sanitize.ts';
1
2
  import { normalizeOutboundReplyToId } from './reply-target-policy.ts';
2
3
 
3
4
  type BncrOutboundMessageType = 'text' | 'image' | 'video' | 'voice' | 'audio' | 'file';
4
5
 
5
- function asString(v: unknown, fallback = ''): string {
6
- if (typeof v === 'string') return v;
7
- if (v == null) return fallback;
8
- return String(v);
9
- }
10
-
11
- function isAudioMimeType(mimeType?: string): boolean {
12
- const mt = asString(mimeType || '').toLowerCase();
13
- return mt.startsWith('audio/');
14
- }
15
-
16
6
  function isBncrOutboundMessageType(value: string): value is BncrOutboundMessageType {
17
7
  return (
18
8
  value === 'text' ||
@@ -43,11 +33,7 @@ export function resolveBncrOutboundMessageType(params: {
43
33
  const isStandard = isBncrOutboundMessageType(hinted);
44
34
 
45
35
  if (hasPayload && major === 'text' && (hinted === 'text' || !isStandard)) return 'file';
46
- if (hinted === 'voice') {
47
- if (isAudioMimeType(mt)) return 'voice';
48
- if (isMimeMajorOutboundMessageType(major)) return major;
49
- return 'file';
50
- }
36
+ if (hinted === 'voice') return 'voice';
51
37
  if (isStandard) return hinted;
52
38
  if (isMimeMajorOutboundMessageType(major)) return major;
53
39
  return 'file';
@@ -61,7 +47,7 @@ export function buildBncrMediaOutboundFrame(params: {
61
47
  mode: 'base64' | 'chunk';
62
48
  mimeType?: string;
63
49
  fileName?: string;
64
- mediaBase64?: string;
50
+ base64?: string;
65
51
  path?: string;
66
52
  };
67
53
  mediaUrl: string;
@@ -87,17 +73,17 @@ export function buildBncrMediaOutboundFrame(params: {
87
73
  type: resolveBncrOutboundMessageType({
88
74
  mimeType: params.media.mimeType,
89
75
  fileName: params.media.fileName,
90
- hasPayload: !!(params.media.path || params.media.mediaBase64),
76
+ hasPayload: !!(params.media.path || params.media.base64),
91
77
  hintedType: params.hintedType,
92
78
  }),
93
79
  kind: params.kind,
94
80
  mimeType: params.media.mimeType || '',
95
81
  msg: params.mediaMsg,
96
82
  path: params.media.path || params.mediaUrl,
97
- base64: params.media.mediaBase64 || '',
83
+ base64: params.media.base64 || '',
98
84
  fileName: params.fileName,
99
85
  transferMode: params.media.mode,
100
- ...(params.extra ? { extra: { ...params.extra } } : {}),
86
+ ...(params.extra ? { ...params.extra } : {}),
101
87
  },
102
88
  ts: params.now,
103
89
  };