@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
@@ -21,7 +21,7 @@ export type BncrSceneAdminCommand =
21
21
  | { kind: 'history-force-set'; sceneKey: string; enabled: boolean | 'clear' }
22
22
  | { kind: 'history-help' }
23
23
  | { kind: 'download-media-get'; sceneKey?: string }
24
- | { kind: 'download-media-set'; sceneKey: string; enabled: boolean }
24
+ | { kind: 'download-media-set'; sceneKey: string; enabled: boolean | undefined }
25
25
  | { kind: 'download-media-global-get' }
26
26
  | { kind: 'download-media-global-set'; enabled: boolean };
27
27
 
@@ -132,7 +132,7 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
132
132
  return {
133
133
  matched: true,
134
134
  valid: true,
135
- command: { kind: 'mode', sceneKey: '', mode: 'clear' as any },
135
+ command: { kind: 'mode', sceneKey: '', mode: 'clear' },
136
136
  };
137
137
  }
138
138
  if (args[0] === 'help') {
@@ -159,7 +159,7 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
159
159
  return {
160
160
  matched: true,
161
161
  valid: true,
162
- command: { kind: 'mode', sceneKey: args[1], mode: 'clear' as any },
162
+ command: { kind: 'mode', sceneKey: args[1], mode: 'clear' },
163
163
  };
164
164
  }
165
165
  if (GROUP_REPLY_MODES.has(args[0] as BncrGroupReplyMode) && args[1]) {
@@ -205,7 +205,7 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
205
205
  return {
206
206
  matched: true,
207
207
  valid: true,
208
- command: { kind: 'history-limit-set', sceneKey: '', limit: 'clear' as any },
208
+ command: { kind: 'history-limit-set', sceneKey: '', limit: 'clear' },
209
209
  };
210
210
  }
211
211
  const num = parseInt(args[0], 10);
@@ -227,7 +227,7 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
227
227
  return {
228
228
  matched: true,
229
229
  valid: true,
230
- command: { kind: 'history-limit-set', sceneKey: args[1], limit: 'clear' as any },
230
+ command: { kind: 'history-limit-set', sceneKey: args[1], limit: 'clear' },
231
231
  };
232
232
  }
233
233
  const num = parseInt(args[0], 10);
@@ -253,13 +253,13 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
253
253
  return {
254
254
  matched: true,
255
255
  valid: true,
256
- command: { kind: 'history-force-set', sceneKey: '', enabled: 'clear' as any },
256
+ command: { kind: 'history-force-set', sceneKey: '', enabled: 'clear' },
257
257
  };
258
258
  }
259
259
  return {
260
260
  matched: true,
261
261
  valid: true,
262
- command: { kind: 'history-force-set', sceneKey: args[1], enabled: 'clear' as any },
262
+ command: { kind: 'history-force-set', sceneKey: args[1], enabled: 'clear' },
263
263
  };
264
264
  }
265
265
  if (args[0] === 'on' || args[0] === 'off') {
@@ -302,7 +302,7 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
302
302
  command: {
303
303
  kind: 'download-media-set',
304
304
  sceneKey: args[1] || '',
305
- enabled: undefined as any,
305
+ enabled: undefined,
306
306
  },
307
307
  };
308
308
  }
@@ -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
- }
@@ -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,
@@ -16,7 +17,6 @@ export {
16
17
  buildOutboxRouteSelectDebugInfo,
17
18
  buildOutboxScheduleDebugInfo,
18
19
  buildPushFailureDebugInfo,
19
- buildReplyMediaFallbackDebugInfo,
20
20
  buildRetryRerouteDebugInfo,
21
21
  } from './diagnostics-debug-builders.ts';
22
22
 
@@ -42,12 +42,6 @@ type OutboxIncidentSummaryInput = {
42
42
  nowMs?: number;
43
43
  };
44
44
 
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
45
  function positiveAgeMs(nowMs: number, at: unknown): number | null {
52
46
  const n = finiteNumberOrNull(at);
53
47
  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';
@@ -6,29 +6,35 @@
6
6
  *
7
7
  * Format: [BncrParam:{"key":"val","asVoice":true}]
8
8
  *
9
- * Incomplete markers (missing closing ]) are left in text unchanged.
10
- * Invalid JSON within a complete marker - marker is removed but no params.
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
+ * - 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.
11
18
  */
12
19
 
13
- const PREFIX = '[BncrParam:';
20
+ import fs from 'node:fs';
14
21
 
15
- export function parseBncrMarker(text: string): {
16
- cleanText: string;
17
- params: Record<string, unknown>;
18
- } {
19
- const idx = text.indexOf(PREFIX);
20
- if (idx === -1) return { cleanText: text, params: {} };
22
+ const PREFIX = '[BncrParam:';
21
23
 
22
- const start = idx + PREFIX.length;
23
- if (start >= text.length) return { cleanText: text, params: {} };
24
+ /**
25
+ * Locate the closing `]` of a complete marker starting at `prefixIdx`.
26
+ * Returns the index of the closing `]`, or -1 if incomplete.
27
+ */
28
+ function findMarkerEnd(text: string, prefixIdx: number): number {
29
+ const start = prefixIdx + PREFIX.length;
30
+ if (start >= text.length) return -1;
24
31
 
25
32
  // Scan character-by-character tracking brace/bracket depth so that
26
- // ] inside JSON values (e.g. CDATA content) is not mistaken for
33
+ // ] inside JSON values (e.g. CDATA / lyric tags) is not mistaken for
27
34
  // the closing marker bracket.
28
35
  let depth = 0;
29
36
  let inString = false;
30
37
  let esc = false;
31
- let end = -1;
32
38
  let started = false;
33
39
 
34
40
  for (let i = start; i < text.length; i++) {
@@ -63,29 +69,62 @@ export function parseBncrMarker(text: string): {
63
69
  if (depth === 0) {
64
70
  // JSON content complete - expect ] to close the marker
65
71
  if (i + 1 < text.length && text[i + 1] === ']') {
66
- end = i + 1;
72
+ return i + 1;
67
73
  }
68
- break;
74
+ return -1;
69
75
  }
70
76
  }
71
77
  }
72
78
  }
73
79
 
74
- if (end === -1) return { cleanText: text, params: {} };
80
+ return -1;
81
+ }
75
82
 
76
- const rawJson = text.slice(start, end);
77
- let params: Record<string, unknown> = {};
83
+ export function parseBncrMarker(text: string): {
84
+ cleanText: string;
85
+ params: Record<string, unknown>;
86
+ } {
87
+ const params: Record<string, unknown> = {};
88
+ let result = text;
89
+ let searchFrom = 0;
78
90
 
79
- try {
80
- const parsed = JSON.parse(rawJson);
81
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
82
- params = parsed as Record<string, unknown>;
91
+ while (true) {
92
+ const idx = result.indexOf(PREFIX, searchFrom);
93
+ if (idx === -1) break;
94
+
95
+ const end = findMarkerEnd(result, idx);
96
+ if (end === -1) {
97
+ // Incomplete marker (missing ]): leave in place,
98
+ // keep looking after this PREFIX so later valid markers are not blocked.
99
+ searchFrom = idx + PREFIX.length;
100
+ continue;
101
+ }
102
+
103
+ const rawJson = result.slice(idx + PREFIX.length, end);
104
+ let parsedOk = false;
105
+ try {
106
+ const parsed = JSON.parse(rawJson);
107
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
108
+ Object.assign(params, parsed as Record<string, unknown>);
109
+ parsedOk = true;
110
+ }
111
+ } catch {
112
+ // JSON parse failed — do NOT strip marker so the user can debug it
113
+ }
114
+
115
+ if (parsedOk) {
116
+ // Strip the complete valid marker
117
+ result = result.slice(0, idx) + result.slice(end + 1);
118
+ searchFrom = idx;
119
+ } else {
120
+ // Leave the invalid marker in place, continue after it
121
+ searchFrom = end + 1;
83
122
  }
84
- } catch {
85
- // Invalid JSON — remove marker but don't apply params
86
123
  }
87
124
 
88
- const cleanText = (text.slice(0, idx) + text.slice(end + 1)).replace(/ {2,}/g, ' ').trim();
125
+ // Only trim whitespace when markers were actually stripped (result changed).
126
+ // Without markers the original text is returned as-is for maximum fidelity.
127
+ const cleanText = result !== text ? result.replace(/ {2,}/g, ' ').trim() : result;
89
128
  return { cleanText, params };
90
129
  }
91
130
 
@@ -103,6 +142,7 @@ const MEDIA_CONSUMPTION_TYPES = new Set(['file', 'image', 'video', 'audio', 'voi
103
142
  * based on whether its value is a recognized media type.
104
143
  */
105
144
  const CONSUMPTION_FIELDS_NO_TYPE = new Set([
145
+ 'ismedia',
106
146
  'asVoice',
107
147
  'audioAsVoice',
108
148
  'kind',
@@ -110,8 +150,6 @@ const CONSUMPTION_FIELDS_NO_TYPE = new Set([
110
150
  'downloadMedia',
111
151
  ]);
112
152
 
113
- const CONSUMPTION_FIELDS = new Set([...CONSUMPTION_FIELDS_NO_TYPE]);
114
-
115
153
  export function extractConsumptionFields(extra: Record<string, unknown> | undefined): {
116
154
  consumed: Partial<{
117
155
  asVoice: boolean;
@@ -120,6 +158,7 @@ export function extractConsumptionFields(extra: Record<string, unknown> | undefi
120
158
  kind: string;
121
159
  replyToId: string;
122
160
  downloadMedia: boolean;
161
+ ismedia: boolean;
123
162
  }>;
124
163
  remaining: Record<string, unknown>;
125
164
  } {
@@ -140,7 +179,7 @@ export function extractConsumptionFields(extra: Record<string, unknown> | undefi
140
179
  }
141
180
  continue;
142
181
  }
143
- if (CONSUMPTION_FIELDS.has(key)) {
182
+ if (CONSUMPTION_FIELDS_NO_TYPE.has(key)) {
144
183
  consumed[key] = value;
145
184
  } else {
146
185
  remaining[key] = value;
@@ -152,3 +191,213 @@ export function extractConsumptionFields(extra: Record<string, unknown> | undefi
152
191
  remaining,
153
192
  };
154
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
+ return { ok: false, reason: `HTTP ${response.status} ${response.statusText}` };
237
+ } catch (err: unknown) {
238
+ const reason = err instanceof Error ? err.message : String(err);
239
+ return { ok: false, reason };
240
+ }
241
+ }
242
+
243
+ // Local file
244
+ try {
245
+ if (fs.existsSync(path)) return { ok: true };
246
+ return { ok: false, reason: 'file not found' };
247
+ } catch {
248
+ return { ok: false, reason: 'unreadable path' };
249
+ }
250
+ }
251
+
252
+ /**
253
+ * After JSON marker parsing, probe any media paths found in the params.
254
+ *
255
+ * - Single path (`path` / `mediaUrl`): if unreachable, keeps the marker in text
256
+ * (returns allValid=false, warnings populated).
257
+ * - Path arrays (`paths` / `mediaUrls`): unreachable entries are removed; if at
258
+ * least one entry is valid the marker is still stripped. If ALL entries are
259
+ * invalid the marker is kept.
260
+ *
261
+ * Returns `{ params, warnings, allValid }`:
262
+ * - `params` — (potentially filtered) params with invalid array entries removed.
263
+ * - `warnings` — human-readable warning strings for each unreachable path.
264
+ * - `allValid` — false when a single path or all array entries are unreachable.
265
+ */
266
+ async function probeAndResolveMarkerPaths(params: Record<string, unknown>): Promise<{
267
+ params: Record<string, unknown>;
268
+ warnings: string[];
269
+ allValid: boolean;
270
+ }> {
271
+ const warnings: string[] = [];
272
+ if (!hasMediaPathParams(params)) return { params, warnings, allValid: true };
273
+
274
+ // Single path / mediaUrl
275
+ const singlePath = String(params.path ?? params.mediaUrl ?? '').trim();
276
+ if (singlePath) {
277
+ const result = await probeMediaPath(singlePath);
278
+ if (!result.ok) {
279
+ warnings.push(`path ${singlePath} unreachable: ${result.reason}`);
280
+ return { params, warnings, allValid: false };
281
+ }
282
+ // Path confirmed reachable — signal downstream that media is validated
283
+ // Only set ismedia:true when user hasn't explicitly opted out.
284
+ if (!('ismedia' in params && params.ismedia === false)) {
285
+ params = { ...params, ismedia: true };
286
+ }
287
+ return { params, warnings, allValid: true };
288
+ }
289
+
290
+ // Path arrays — paths / mediaUrls
291
+ const arraySource = (params.paths ?? params.mediaUrls) as unknown;
292
+ if (Array.isArray(arraySource) && arraySource.length > 0) {
293
+ const entries = arraySource.filter(
294
+ (v): v is string => typeof v === 'string' && v.trim().length > 0,
295
+ );
296
+ if (entries.length === 0) return { params, warnings, allValid: true };
297
+
298
+ const results = await Promise.all(entries.map((p) => probeMediaPath(p)));
299
+ const valid: string[] = [];
300
+ for (let i = 0; i < entries.length; i++) {
301
+ if (results[i].ok) {
302
+ valid.push(entries[i]);
303
+ } else {
304
+ warnings.push(`path ${entries[i]} unreachable: ${results[i].reason}`);
305
+ }
306
+ }
307
+
308
+ if (valid.length === 0) {
309
+ // All invalid → keep marker
310
+ return { params, warnings, allValid: false };
311
+ }
312
+ // At least one valid → strip marker, replace with filtered entries.
313
+ // Only set ismedia=true when user hasn't explicitly opted out.
314
+ const setMedia = !('ismedia' in params && params.ismedia === false);
315
+ params = { ...params, ...(setMedia ? { ismedia: true } : {}), paths: valid, mediaUrls: valid };
316
+ }
317
+
318
+ return { params, warnings, allValid: true };
319
+ }
320
+
321
+ /**
322
+ * Parse text markers AND probe media paths in one async step.
323
+ *
324
+ /**
325
+ * Unified marker strip decision: the single place that decides whether to strip
326
+ * a [BncrParam:...] marker from the text or leave it in place.
327
+ *
328
+ * - Markers that JSON parse successfully AND whose media paths (if any) are
329
+ * reachable → stripped from text, params extracted and merged.
330
+ * - Markers whose paths are unreachable (single path OR all array entries) →
331
+ * kept in text, NOT stripped, NOT extracted.
332
+ * - Markers without path fields → stripped as normal (existing behavior).
333
+ * - Markers that fail JSON parse → kept in text (existing behavior).
334
+ * - Multiple markers → each evaluated independently, later markers' params
335
+ * overwrite earlier ones when the same key appears.
336
+ *
337
+ * This function IS the single decision point. Downstream consumers of the
338
+ * returned text never re-parse markers — they trust the decision made here.
339
+ */
340
+ export async function resolveMarkerStripDecision(text: string): Promise<{
341
+ cleanText: string;
342
+ params: Record<string, unknown>;
343
+ warnings: string[];
344
+ }> {
345
+ const params: Record<string, unknown> = {};
346
+ const warnings: string[] = [];
347
+ let result = text;
348
+ let searchFrom = 0;
349
+ const hadMarkers = text.includes(PREFIX);
350
+
351
+ if (!hadMarkers) return { cleanText: text, params, warnings };
352
+
353
+ while (true) {
354
+ const idx = result.indexOf(PREFIX, searchFrom);
355
+ if (idx === -1) break;
356
+
357
+ const end = findMarkerEnd(result, idx);
358
+ if (end === -1) {
359
+ searchFrom = idx + PREFIX.length;
360
+ continue;
361
+ }
362
+
363
+ const rawJson = result.slice(idx + PREFIX.length, end);
364
+ let parsed: Record<string, unknown> | null = null;
365
+ try {
366
+ const obj = JSON.parse(rawJson);
367
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
368
+ parsed = obj as Record<string, unknown>;
369
+ }
370
+ } catch {
371
+ // JSON parse failed → keep marker in text, continue scanning after it
372
+ searchFrom = end + 1;
373
+ continue;
374
+ }
375
+
376
+ // JSON parsed but is not a plain object → keep marker in text
377
+ if (!parsed) {
378
+ searchFrom = end + 1;
379
+ continue;
380
+ }
381
+
382
+ // Marker has path fields → probe before deciding to strip
383
+ if (hasMediaPathParams(parsed)) {
384
+ const probeResult = await probeAndResolveMarkerPaths(parsed);
385
+ if (probeResult.warnings.length > 0) warnings.push(...probeResult.warnings);
386
+ if (!probeResult.allValid) {
387
+ // Path(s) unreachable → keep marker in text, do NOT extract any params
388
+ searchFrom = end + 1;
389
+ continue;
390
+ }
391
+ // Paths valid → strip, merge params (probeResult.params includes ismedia:true + filtered arrays)
392
+ Object.assign(params, probeResult.params);
393
+ } else {
394
+ // No path fields → strip marker and merge params unconditionally
395
+ Object.assign(params, parsed);
396
+ }
397
+ result = result.slice(0, idx) + result.slice(end + 1);
398
+ searchFrom = idx;
399
+ }
400
+
401
+ const cleanText = result !== text ? result.replace(/ {2,}/g, ' ').trim() : result;
402
+ return { cleanText, params, warnings };
403
+ }
@@ -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
  }
@@ -19,10 +15,7 @@ export function normalizeMessageText(value: unknown): string {
19
15
  return asString(value || '').trim();
20
16
  }
21
17
 
22
- export function shouldTreatReplyToAsSame(
23
- currentReplyToId: string,
24
- previousReplyToId: string,
25
- ): boolean {
18
+ function shouldTreatReplyToAsSame(currentReplyToId: string, previousReplyToId: string): boolean {
26
19
  if (!currentReplyToId || !previousReplyToId) return true;
27
20
  return currentReplyToId === previousReplyToId;
28
21
  }
@@ -1,13 +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
6
  function isBncrOutboundMessageType(value: string): value is BncrOutboundMessageType {
12
7
  return (
13
8
  value === 'text' ||