@xmoxmo/bncr 0.5.7 → 0.5.9

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 (44) hide show
  1. package/package.json +1 -1
  2. package/scripts/check-pack.mjs +0 -2
  3. package/scripts/selfcheck.mjs +48 -13
  4. package/src/bootstrap/runtime-loader.ts +1 -1
  5. package/src/channel.ts +50 -138
  6. package/src/core/connection-reachability.ts +1 -1
  7. package/src/core/logging.ts +1 -1
  8. package/src/core/outbox-entry-builders.ts +60 -82
  9. package/src/core/outbox-summary.ts +5 -39
  10. package/src/core/permissions.ts +1 -1
  11. package/src/core/register-trace.ts +2 -2
  12. package/src/messaging/inbound/parse.ts +1 -1
  13. package/src/messaging/outbound/diagnostics-debug-builders.ts +0 -15
  14. package/src/messaging/outbound/diagnostics.ts +0 -1
  15. package/src/messaging/outbound/marker-parser.ts +234 -0
  16. package/src/messaging/outbound/media-dedupe.ts +1 -4
  17. package/src/messaging/outbound/normalize-outbound-send.ts +9 -7
  18. package/src/messaging/outbound/reply-enqueue-media.ts +1 -184
  19. package/src/messaging/outbound/reply-enqueue.ts +249 -162
  20. package/src/openclaw/media-runtime.ts +1 -1
  21. package/src/plugin/ack-outbox-runtime-group.ts +2 -0
  22. package/src/plugin/bridge-media-facade.ts +0 -3
  23. package/src/plugin/bridge-outbox-facade.ts +14 -2
  24. package/src/plugin/channel-runtime-builders-delivery.ts +2 -362
  25. package/src/plugin/channel-runtime-builders-status.ts +0 -160
  26. package/src/plugin/channel-runtime-builders.ts +3 -17
  27. package/src/plugin/channel-runtime-constants.ts +1 -1
  28. package/src/plugin/channel-send.ts +5 -4
  29. package/src/plugin/file-transfer-orchestrator.ts +39 -2
  30. package/src/plugin/media-orchestrators-runtime-group.ts +20 -149
  31. package/src/plugin/messaging.ts +3 -3
  32. package/src/plugin/outbox-drain-failure.ts +34 -1
  33. package/src/plugin/outbox-drain-runtime.ts +3 -0
  34. package/src/plugin/outbox-push.ts +1 -52
  35. package/src/plugin/outbox-unified-push-flow.ts +307 -0
  36. package/src/plugin/scene-registry.ts +1 -1
  37. package/src/runtime/log-dedupe.ts +3 -3
  38. package/src/runtime/outbound-ack-timeout.ts +2 -4
  39. package/src/runtime/status-snapshots.ts +2 -2
  40. package/src/runtime/status-worker.ts +2 -6
  41. package/src/core/outbox-text-push-prep.ts +0 -1
  42. package/src/plugin/bridge-file-transfer-push-facade.ts +0 -142
  43. package/src/plugin/outbox-file-push-flow.ts +0 -69
  44. package/src/plugin/outbox-text-push-flow.ts +0 -90
@@ -12,27 +12,67 @@ function sanitizeExtraSpread(extra: Record<string, unknown>): Record<string, unk
12
12
  return out;
13
13
  }
14
14
 
15
- export function buildFileTransferOutboxEntry(args: {
15
+ export type BuildOutboxEntryParams = {
16
16
  createMessageId: () => string;
17
17
  now: () => number;
18
18
  normalizeAccountId: (accountId?: string | null) => string;
19
19
  accountId: string;
20
20
  sessionKey: string;
21
21
  route: BncrRoute;
22
- mediaUrl: string;
23
- mediaLocalRoots?: readonly string[];
24
- text: string;
25
- asVoice?: boolean;
26
- audioAsVoice?: boolean;
22
+ /** For text sends this is 'text'; for media it may be undefined (inferred downstream). */
27
23
  type?: string;
28
- extra?: Record<string, unknown>;
24
+ /** Message body (text content for text sends, caption for media sends). */
25
+ msg: string;
29
26
  kind?: 'tool' | 'block' | 'final';
30
27
  replyToId?: string;
31
28
  replyTargetPolicy?: OutboundReplyTargetPolicy;
29
+ extra?: Record<string, unknown>;
30
+ /** Media-only fields. When transferMode === 'media' these shape the frame. */
31
+ transferMode?: 'text' | 'media';
32
+ mediaUrl?: string;
33
+ mediaLocalRoots?: readonly string[];
34
+ asVoice?: boolean;
35
+ audioAsVoice?: boolean;
32
36
  downloadMedia?: boolean;
33
- }): OutboxEntry {
37
+ };
38
+
39
+ /**
40
+ * Unified outbox entry builder.
41
+ *
42
+ * Text and media sends share the same outbox lifecycle (queue, drain, retry).
43
+ * The only structural difference is that media entries carry extra media fields
44
+ * and set `transferMode: 'media'` so the push layer knows to process media.
45
+ *
46
+ * - **Text**: `{ type: 'text', transferMode: undefined, no media fields }`
47
+ * - **Media**: `{ type: args.type || undefined, transferMode: 'media', ...media fields }`
48
+ */
49
+ export function buildOutboxEntry(args: BuildOutboxEntryParams): OutboxEntry {
34
50
  const messageId = args.createMessageId();
35
51
  const createdAt = args.now();
52
+ const isMedia = args.transferMode === 'media';
53
+
54
+ const message: Record<string, unknown> = {
55
+ platform: args.route.platform,
56
+ groupId: args.route.groupId,
57
+ userId: args.route.userId,
58
+ type: isMedia ? args.type : 'text',
59
+ kind: args.kind,
60
+ msg: args.msg,
61
+ path: '',
62
+ base64: '',
63
+ fileName: '',
64
+ ...(args.extra ? sanitizeExtraSpread(args.extra) : {}),
65
+ };
66
+
67
+ if (isMedia) {
68
+ message.mediaUrl = args.mediaUrl || '';
69
+ if (args.mediaLocalRoots) message.mediaLocalRoots = Array.from(args.mediaLocalRoots);
70
+ message.asVoice = args.asVoice === true;
71
+ message.audioAsVoice = args.audioAsVoice === true;
72
+ message.downloadMedia = args.downloadMedia;
73
+ message.transferMode = 'media';
74
+ }
75
+
36
76
  return {
37
77
  messageId,
38
78
  accountId: args.normalizeAccountId(args.accountId),
@@ -49,84 +89,22 @@ export function buildFileTransferOutboxEntry(args: {
49
89
  replyToId: args.replyToId,
50
90
  replyTargetPolicy: args.replyTargetPolicy,
51
91
  }) || undefined,
52
- message: {
53
- platform: args.route.platform,
54
- groupId: args.route.groupId,
55
- userId: args.route.userId,
56
- type: args.type,
57
- // No fallback: when type is not provided the downstream adapter
58
- // (tgBot / GewePlus) infers from mediaUrl extension.
59
- kind: args.kind,
60
- msg: args.text,
61
- mediaUrl: args.mediaUrl,
62
- mediaLocalRoots: args.mediaLocalRoots ? Array.from(args.mediaLocalRoots) : undefined,
63
- asVoice: args.asVoice === true,
64
- audioAsVoice: args.audioAsVoice === true,
65
- downloadMedia: args.downloadMedia,
66
- transferMode: 'media',
67
- path: '',
68
- base64: '',
69
- fileName: '',
70
- ...(args.extra ? sanitizeExtraSpread(args.extra) : {}),
92
+ message: message as {
93
+ platform: string;
94
+ groupId: string;
95
+ userId: string;
96
+ type?: string;
97
+ kind?: 'tool' | 'block' | 'final';
98
+ msg: string;
99
+ path: string;
100
+ base64: string;
101
+ fileName: string;
102
+ [key: string]: unknown;
71
103
  },
104
+ ts: createdAt,
72
105
  },
73
106
  createdAt,
74
107
  retryCount: 0,
75
108
  nextAttemptAt: createdAt,
76
109
  };
77
110
  }
78
-
79
- export function buildTextOutboxEntry(args: {
80
- createMessageId: () => string;
81
- now: () => number;
82
- normalizeAccountId: (accountId?: string | null) => string;
83
- accountId: string;
84
- extra?: Record<string, unknown>;
85
- sessionKey: string;
86
- route: BncrRoute;
87
- text: string;
88
- kind?: 'tool' | 'block' | 'final';
89
- replyToId?: string;
90
- replyTargetPolicy?: OutboundReplyTargetPolicy;
91
- }): OutboxEntry {
92
- const messageId = args.createMessageId();
93
- const createdAt = args.now();
94
- const frame = {
95
- type: 'message.outbound',
96
- messageId,
97
- idempotencyKey: messageId,
98
- sessionKey: args.sessionKey,
99
- replyToId:
100
- normalizeOutboundReplyToId({
101
- kind: args.kind,
102
- replyToId: args.replyToId,
103
- replyTargetPolicy: args.replyTargetPolicy,
104
- }) || undefined,
105
- message: {
106
- platform: args.route.platform,
107
- groupId: args.route.groupId,
108
- userId: args.route.userId,
109
- type: 'text',
110
- kind: args.kind,
111
- msg: args.text,
112
- path: '',
113
- base64: '',
114
- fileName: '',
115
- // Extra fields from marker/pipeline spread for adapter-specific data
116
- // (e.g. type="appmsg", msg="<appmsg>..." from [BncrParam:...])
117
- ...(args.extra ? sanitizeExtraSpread(args.extra) : {}),
118
- },
119
- ts: createdAt,
120
- };
121
-
122
- return {
123
- messageId,
124
- accountId: args.normalizeAccountId(args.accountId),
125
- sessionKey: args.sessionKey,
126
- route: args.route,
127
- payload: frame,
128
- createdAt,
129
- retryCount: 0,
130
- nextAttemptAt: createdAt,
131
- };
132
- }
@@ -1,26 +1,6 @@
1
1
  import type { OutboxEntry } from './types.ts';
2
2
 
3
- type OutboxSummaryMessage = {
4
- type?: unknown;
5
- msg?: unknown;
6
- mediaUrl?: unknown;
7
- audioAsVoice?: boolean;
8
- asVoice?: boolean;
9
- };
10
-
11
- type OutboxSummaryPayload = {
12
- type?: unknown;
13
- message?: OutboxSummaryMessage;
14
- };
15
-
16
- function inferMediaTypeFromUrl(raw: string): 'image' | 'video' | 'audio' | 'file' {
17
- const clean = String(raw || '').split(/[?#]/, 1)[0] || '';
18
- const ext = clean.includes('.') ? clean.slice(clean.lastIndexOf('.') + 1).toLowerCase() : '';
19
- if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif'].includes(ext)) return 'image';
20
- if (['mp4', 'mov', 'mkv', 'webm', 'avi', 'm4v'].includes(ext)) return 'video';
21
- if (['mp3', 'wav', 'ogg', 'oga', 'opus', 'm4a', 'aac', 'flac'].includes(ext)) return 'audio';
22
- return 'file';
23
- }
3
+ type OutboxSummaryMessage = { type?: unknown; msg?: unknown; mediaUrl?: unknown };
24
4
 
25
5
  function filenameFromUrl(raw: string): string {
26
6
  const clean = String(raw || '').split(/[?#]/, 1)[0] || '';
@@ -33,27 +13,13 @@ function filenameFromUrl(raw: string): string {
33
13
  }
34
14
  }
35
15
 
36
- function summarizeOutboxType(
37
- payload: OutboxSummaryPayload,
38
- msg: OutboxSummaryMessage,
39
- asString: (value: unknown) => string,
40
- ) {
41
- const directType = asString(msg.type || '');
42
- if (directType) return directType;
43
- // Infer type from mediaUrl when no direct type is set
44
- const mediaUrl = asString(msg.mediaUrl || '');
45
- if (mediaUrl) {
46
- if (msg.audioAsVoice === true || msg.asVoice === true) return 'voice';
47
- const inferred = inferMediaTypeFromUrl(mediaUrl);
48
- if (inferred) return inferred;
49
- }
50
- return asString(payload?.type || 'unknown');
16
+ function summarizeOutboxType(msg: OutboxSummaryMessage, asString: (value: unknown) => string) {
17
+ return asString(msg.type || '') || 'unknown';
51
18
  }
52
19
 
53
20
  function summarizeOutboxText(msg: OutboxSummaryMessage, asString: (value: unknown) => string) {
54
21
  const text = asString(msg.msg || '').trim();
55
22
  if (text) return text;
56
- // Fall back to filename from mediaUrl when no caption is set
57
23
  const mediaUrl = asString(msg.mediaUrl || '');
58
24
  if (mediaUrl) return filenameFromUrl(mediaUrl);
59
25
  return '';
@@ -65,9 +31,9 @@ export function summarizeOutboxEntry(args: {
65
31
  formatDisplayScope: (route: OutboxEntry['route']) => string;
66
32
  summarizeTextPreview: (raw: string, limit?: number) => string;
67
33
  }) {
68
- const payload = args.entry.payload as OutboxSummaryPayload;
34
+ const payload = args.entry.payload as { message?: OutboxSummaryMessage } | undefined;
69
35
  const msg = payload?.message || {};
70
- const type = summarizeOutboxType(payload, msg, args.asString);
36
+ const type = summarizeOutboxType(msg, args.asString);
71
37
  const text = summarizeOutboxText(msg, args.asString);
72
38
  const preview = args.summarizeTextPreview(text);
73
39
  return [type, args.formatDisplayScope(args.entry.route), preview].join('|');
@@ -10,7 +10,7 @@ type BncrElevatedConfigRoot = {
10
10
  };
11
11
  };
12
12
  };
13
- export function getBncrElevatedConfig(rootCfg: BncrElevatedConfigRoot | null | undefined) {
13
+ function getBncrElevatedConfig(rootCfg: BncrElevatedConfigRoot | null | undefined) {
14
14
  const elevated = rootCfg?.tools?.elevated || {};
15
15
  const allowFrom = elevated?.allowFrom || {};
16
16
  const bncrRules = Array.isArray(allowFrom?.bncr)
@@ -43,7 +43,7 @@ export type RegisterDriftSnapshot = {
43
43
  traceRecent: Array<Record<string, unknown>>;
44
44
  };
45
45
 
46
- export function classifyRegisterTrace(stack: string) {
46
+ function classifyRegisterTrace(stack: string) {
47
47
  if (
48
48
  stack.includes('prepareSecretsRuntimeSnapshot') ||
49
49
  stack.includes('resolveRuntimeWebTools') ||
@@ -66,7 +66,7 @@ export function classifyRegisterTrace(stack: string) {
66
66
  return 'other';
67
67
  }
68
68
 
69
- export function dominantRegisterBucket(sourceBuckets: Record<string, number>) {
69
+ function dominantRegisterBucket(sourceBuckets: Record<string, number>) {
70
70
  let winner: string | null = null;
71
71
  let winnerCount = -1;
72
72
  for (const [bucket, count] of Object.entries(sourceBuckets)) {
@@ -58,7 +58,7 @@ function asInboundMediaItems(params: BncrInboundParamsInput): BncrInboundMediaIt
58
58
  }));
59
59
  }
60
60
 
61
- export function inboundDedupKey(params: {
61
+ function inboundDedupKey(params: {
62
62
  accountId: string;
63
63
  platform: string;
64
64
  groupId: string;
@@ -348,18 +348,3 @@ export function buildPushFailureDebugInfo(args: {
348
348
  (typeof args.lastError === 'string' && args.lastError) || OUTBOUND_TERMINAL_REASON.PUSH_RETRY,
349
349
  };
350
350
  }
351
-
352
- export function buildReplyMediaFallbackDebugInfo(args: {
353
- sessionKey: string;
354
- mediaUrl: string;
355
- replyToId: string;
356
- fallback: { text: string; reason: string };
357
- }) {
358
- return {
359
- sessionKey: args.sessionKey,
360
- mediaUrl: args.mediaUrl,
361
- replyToId: args.replyToId || undefined,
362
- fallbackText: args.fallback.text,
363
- reason: args.fallback.reason,
364
- };
365
- }
@@ -17,7 +17,6 @@ export {
17
17
  buildOutboxRouteSelectDebugInfo,
18
18
  buildOutboxScheduleDebugInfo,
19
19
  buildPushFailureDebugInfo,
20
- buildReplyMediaFallbackDebugInfo,
21
20
  buildRetryRerouteDebugInfo,
22
21
  } from './diagnostics-debug-builders.ts';
23
22
 
@@ -12,8 +12,13 @@
12
12
  * - Incomplete marker (no closing ]) → left in text, does NOT block later
13
13
  * complete markers.
14
14
  * - Multiple valid markers → all stripped, params merge (later same-key wins).
15
+ * - When path/paths/mediaUrl/mediaUrls are present, probe for reachability.
16
+ * Unreachable single paths keep the marker visible in the output text.
17
+ * Unreachable entries in path arrays are dropped with warnings logged.
15
18
  */
16
19
 
20
+ import fs from 'node:fs';
21
+
17
22
  const PREFIX = '[BncrParam:';
18
23
 
19
24
  /**
@@ -186,3 +191,232 @@ export function extractConsumptionFields(extra: Record<string, unknown> | undefi
186
191
  remaining,
187
192
  };
188
193
  }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Media path probing
197
+ // ---------------------------------------------------------------------------
198
+
199
+ const PROBE_TIMEOUT_MS = 5_000;
200
+ const MEDIA_PATH_KEYS = new Set(['path', 'paths', 'mediaUrl', 'mediaUrls']);
201
+
202
+ function hasMediaPathParams(params: Record<string, unknown>): boolean {
203
+ for (const key of MEDIA_PATH_KEYS) {
204
+ if (key in params) return true;
205
+ }
206
+ return false;
207
+ }
208
+
209
+ /**
210
+ * Check whether a single media path (HTTP URL or local file) is reachable.
211
+ *
212
+ * - HTTP/HTTPS URLs → HEAD request with short timeout (5 s), follows redirects.
213
+ * - Local files → fs.existsSync.
214
+ *
215
+ * Returns `{ ok, reason }` where `reason` describes the failure when `ok` is false.
216
+ */
217
+ async function probeMediaPath(raw: string): Promise<{
218
+ ok: boolean;
219
+ reason?: string;
220
+ }> {
221
+ const path = String(raw || '').trim();
222
+ if (!path) return { ok: false, reason: 'empty path' };
223
+
224
+ // HTTP(S) URL — HEAD probe
225
+ if (path.startsWith('http://') || path.startsWith('https://')) {
226
+ try {
227
+ const controller = new AbortController();
228
+ const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
229
+ const response = await fetch(path, {
230
+ method: 'HEAD',
231
+ signal: controller.signal,
232
+ redirect: 'follow',
233
+ });
234
+ clearTimeout(timer);
235
+ if (response.ok || response.status === 206) return { ok: true };
236
+ // HEAD failed — try Range GET (first 4 bytes) as fallback.
237
+ // Some CDNs / object stores reject HEAD but serve GET with Range.
238
+ const probeReason = `HEAD ${response.status}`;
239
+ try {
240
+ const controller2 = new AbortController();
241
+ const timer2 = setTimeout(() => controller2.abort(), PROBE_TIMEOUT_MS);
242
+ const rangeResp = await fetch(path, {
243
+ method: 'GET',
244
+ headers: { Range: 'bytes=0-3' },
245
+ signal: controller2.signal,
246
+ redirect: 'follow',
247
+ });
248
+ clearTimeout(timer2);
249
+ if (rangeResp.status === 206 || rangeResp.ok) return { ok: true }; // 206=Range ok, other 2xx=server reachable
250
+ return { ok: false, reason: `${probeReason}, Range GET ${rangeResp.status}` };
251
+ } catch (err2: unknown) {
252
+ const reason2 = err2 instanceof Error ? err2.message : String(err2);
253
+ return { ok: false, reason: `${probeReason}, Range GET error: ${reason2}` };
254
+ }
255
+ } catch (err: unknown) {
256
+ const reason = err instanceof Error ? err.message : String(err);
257
+ // fetch-level error (DNS / timeout) — don't attempt fallback
258
+ return { ok: false, reason };
259
+ }
260
+ }
261
+
262
+ // Local file
263
+ try {
264
+ if (fs.existsSync(path)) return { ok: true };
265
+ return { ok: false, reason: 'file not found' };
266
+ } catch {
267
+ return { ok: false, reason: 'unreadable path' };
268
+ }
269
+ }
270
+
271
+ /**
272
+ * After JSON marker parsing, probe any media paths found in the params.
273
+ *
274
+ * - Single path (`path` / `mediaUrl`): if unreachable, keeps the marker in text
275
+ * (returns allValid=false, warnings populated).
276
+ * - Path arrays (`paths` / `mediaUrls`): unreachable entries are removed; if at
277
+ * least one entry is valid the marker is still stripped. If ALL entries are
278
+ * invalid the marker is kept.
279
+ *
280
+ * Returns `{ params, warnings, allValid }`:
281
+ * - `params` — (potentially filtered) params with invalid array entries removed.
282
+ * - `warnings` — human-readable warning strings for each unreachable path.
283
+ * - `allValid` — false when a single path or all array entries are unreachable.
284
+ */
285
+ async function probeAndResolveMarkerPaths(params: Record<string, unknown>): Promise<{
286
+ params: Record<string, unknown>;
287
+ warnings: string[];
288
+ allValid: boolean;
289
+ }> {
290
+ const warnings: string[] = [];
291
+ if (!hasMediaPathParams(params)) return { params, warnings, allValid: true };
292
+
293
+ // Single path / mediaUrl
294
+ const singlePath = String(params.path ?? params.mediaUrl ?? '').trim();
295
+ if (singlePath) {
296
+ const result = await probeMediaPath(singlePath);
297
+ if (!result.ok) {
298
+ warnings.push(`path ${singlePath} unreachable: ${result.reason}`);
299
+ return { params, warnings, allValid: false };
300
+ }
301
+ // Path confirmed reachable — signal downstream that media is validated
302
+ // Only set ismedia:true when user hasn't explicitly opted out.
303
+ if (!('ismedia' in params && params.ismedia === false)) {
304
+ params = { ...params, ismedia: true };
305
+ }
306
+ return { params, warnings, allValid: true };
307
+ }
308
+
309
+ // Path arrays — paths / mediaUrls
310
+ const arraySource = (params.paths ?? params.mediaUrls) as unknown;
311
+ if (Array.isArray(arraySource) && arraySource.length > 0) {
312
+ const entries = arraySource.filter(
313
+ (v): v is string => typeof v === 'string' && v.trim().length > 0,
314
+ );
315
+ if (entries.length === 0) return { params, warnings, allValid: true };
316
+
317
+ const results = await Promise.all(entries.map((p) => probeMediaPath(p)));
318
+ const valid: string[] = [];
319
+ for (let i = 0; i < entries.length; i++) {
320
+ if (results[i].ok) {
321
+ valid.push(entries[i]);
322
+ } else {
323
+ warnings.push(`path ${entries[i]} unreachable: ${results[i].reason}`);
324
+ }
325
+ }
326
+
327
+ if (valid.length === 0) {
328
+ // All invalid → keep marker
329
+ return { params, warnings, allValid: false };
330
+ }
331
+ // At least one valid → strip marker, replace with filtered entries.
332
+ // Only set ismedia=true when user hasn't explicitly opted out.
333
+ const setMedia = !('ismedia' in params && params.ismedia === false);
334
+ params = { ...params, ...(setMedia ? { ismedia: true } : {}), paths: valid, mediaUrls: valid };
335
+ }
336
+
337
+ return { params, warnings, allValid: true };
338
+ }
339
+
340
+ /**
341
+ * Parse text markers AND probe media paths in one async step.
342
+ *
343
+ /**
344
+ * Unified marker strip decision: the single place that decides whether to strip
345
+ * a [BncrParam:...] marker from the text or leave it in place.
346
+ *
347
+ * - Markers that JSON parse successfully AND whose media paths (if any) are
348
+ * reachable → stripped from text, params extracted and merged.
349
+ * - Markers whose paths are unreachable (single path OR all array entries) →
350
+ * kept in text, NOT stripped, NOT extracted.
351
+ * - Markers without path fields → stripped as normal (existing behavior).
352
+ * - Markers that fail JSON parse → kept in text (existing behavior).
353
+ * - Multiple markers → each evaluated independently, later markers' params
354
+ * overwrite earlier ones when the same key appears.
355
+ *
356
+ * This function IS the single decision point. Downstream consumers of the
357
+ * returned text never re-parse markers — they trust the decision made here.
358
+ */
359
+ export async function resolveMarkerStripDecision(text: string): Promise<{
360
+ cleanText: string;
361
+ params: Record<string, unknown>;
362
+ warnings: string[];
363
+ }> {
364
+ const params: Record<string, unknown> = {};
365
+ const warnings: string[] = [];
366
+ let result = text;
367
+ let searchFrom = 0;
368
+ const hadMarkers = text.includes(PREFIX);
369
+
370
+ if (!hadMarkers) return { cleanText: text, params, warnings };
371
+
372
+ while (true) {
373
+ const idx = result.indexOf(PREFIX, searchFrom);
374
+ if (idx === -1) break;
375
+
376
+ const end = findMarkerEnd(result, idx);
377
+ if (end === -1) {
378
+ searchFrom = idx + PREFIX.length;
379
+ continue;
380
+ }
381
+
382
+ const rawJson = result.slice(idx + PREFIX.length, end);
383
+ let parsed: Record<string, unknown> | null = null;
384
+ try {
385
+ const obj = JSON.parse(rawJson);
386
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
387
+ parsed = obj as Record<string, unknown>;
388
+ }
389
+ } catch {
390
+ // JSON parse failed → keep marker in text, continue scanning after it
391
+ searchFrom = end + 1;
392
+ continue;
393
+ }
394
+
395
+ // JSON parsed but is not a plain object → keep marker in text
396
+ if (!parsed) {
397
+ searchFrom = end + 1;
398
+ continue;
399
+ }
400
+
401
+ // Marker has path fields → probe before deciding to strip
402
+ if (hasMediaPathParams(parsed)) {
403
+ const probeResult = await probeAndResolveMarkerPaths(parsed);
404
+ if (probeResult.warnings.length > 0) warnings.push(...probeResult.warnings);
405
+ if (!probeResult.allValid) {
406
+ // Path(s) unreachable → keep marker in text, do NOT extract any params
407
+ searchFrom = end + 1;
408
+ continue;
409
+ }
410
+ // Paths valid → strip, merge params (probeResult.params includes ismedia:true + filtered arrays)
411
+ Object.assign(params, probeResult.params);
412
+ } else {
413
+ // No path fields → strip marker and merge params unconditionally
414
+ Object.assign(params, parsed);
415
+ }
416
+ result = result.slice(0, idx) + result.slice(end + 1);
417
+ searchFrom = idx;
418
+ }
419
+
420
+ const cleanText = result !== text ? result.replace(/ {2,}/g, ' ').trim() : result;
421
+ return { cleanText, params, warnings };
422
+ }
@@ -15,10 +15,7 @@ export function normalizeMessageText(value: unknown): string {
15
15
  return asString(value || '').trim();
16
16
  }
17
17
 
18
- export function shouldTreatReplyToAsSame(
19
- currentReplyToId: string,
20
- previousReplyToId: string,
21
- ): boolean {
18
+ function shouldTreatReplyToAsSame(currentReplyToId: string, previousReplyToId: string): boolean {
22
19
  if (!currentReplyToId || !previousReplyToId) return true;
23
20
  return currentReplyToId === previousReplyToId;
24
21
  }
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { asString, isPlainObject } from '../../core/value-sanitize.ts';
15
- import { extractConsumptionFields, parseBncrMarker } from './marker-parser.ts';
15
+ import { extractConsumptionFields, resolveMarkerStripDecision } from './marker-parser.ts';
16
16
 
17
17
  const MEDIA_SOURCE_EXTRA_KEYS = ['path', 'paths', 'mediaUrl', 'mediaUrls'] as const;
18
18
 
@@ -104,13 +104,13 @@ function buildBranchExtra(args: {
104
104
  * Parse markers from one or more text fields and merge extras.
105
105
  * Marker params always override host/params extra and host control flags.
106
106
  */
107
- export function mergeMarkerAndHostFields(args: {
107
+ export async function mergeMarkerAndHostFields(args: {
108
108
  texts: string[];
109
109
  hostExtra?: Record<string, unknown>;
110
110
  forceDocument?: boolean;
111
111
  gifPlayback?: boolean;
112
112
  silent?: boolean;
113
- }): {
113
+ }): Promise<{
114
114
  cleanTexts: string[];
115
115
  markerParams: Record<string, unknown>;
116
116
  consumed: Partial<{
@@ -124,12 +124,12 @@ export function mergeMarkerAndHostFields(args: {
124
124
  }>;
125
125
  remaining: Record<string, unknown>;
126
126
  markerHasMsg: boolean;
127
- } {
127
+ }> {
128
128
  const markerParams: Record<string, unknown> = {};
129
129
  const cleanTexts: string[] = [];
130
130
 
131
131
  for (const raw of args.texts) {
132
- const { cleanText, params } = parseBncrMarker(raw);
132
+ const { cleanText, params } = await resolveMarkerStripDecision(raw);
133
133
  cleanTexts.push(cleanText);
134
134
  Object.assign(markerParams, params);
135
135
  }
@@ -222,13 +222,15 @@ function resolveDispatchMediaSources(args: {
222
222
  * Normalize any outbound send input into a single dispatch shape.
223
223
  * Callers only need to branch on `hasMedia` after this.
224
224
  */
225
- export function normalizeOutboundSend(input: UnifiedOutboundSendInput): NormalizedOutboundSend {
225
+ export async function normalizeOutboundSend(
226
+ input: UnifiedOutboundSendInput,
227
+ ): Promise<NormalizedOutboundSend> {
226
228
  const rawText = asString(input.text ?? input.message ?? '', '');
227
229
  const rawCaption = asString(input.caption ?? '', '');
228
230
  // Prefer text/message; also parse caption so marker in either field works.
229
231
  const textsToParse = rawCaption && rawCaption !== rawText ? [rawText, rawCaption] : [rawText];
230
232
 
231
- const { cleanTexts, consumed, remaining, markerHasMsg } = mergeMarkerAndHostFields({
233
+ const { cleanTexts, consumed, remaining, markerHasMsg } = await mergeMarkerAndHostFields({
232
234
  texts: textsToParse,
233
235
  hostExtra: isPlainObject(input.extra) ? input.extra : undefined,
234
236
  forceDocument: input.forceDocument,