@xmoxmo/bncr 0.5.7 → 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 (41) hide show
  1. package/package.json +1 -1
  2. package/scripts/check-pack.mjs +0 -2
  3. package/scripts/selfcheck.mjs +0 -3
  4. package/src/bootstrap/runtime-loader.ts +1 -1
  5. package/src/channel.ts +49 -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 +215 -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/bridge-media-facade.ts +0 -3
  22. package/src/plugin/bridge-outbox-facade.ts +0 -1
  23. package/src/plugin/channel-runtime-builders-delivery.ts +2 -362
  24. package/src/plugin/channel-runtime-builders-status.ts +0 -160
  25. package/src/plugin/channel-runtime-builders.ts +3 -17
  26. package/src/plugin/channel-runtime-constants.ts +1 -1
  27. package/src/plugin/channel-send.ts +5 -4
  28. package/src/plugin/file-transfer-orchestrator.ts +39 -2
  29. package/src/plugin/media-orchestrators-runtime-group.ts +20 -149
  30. package/src/plugin/messaging.ts +3 -3
  31. package/src/plugin/outbox-push.ts +1 -52
  32. package/src/plugin/outbox-unified-push-flow.ts +307 -0
  33. package/src/plugin/scene-registry.ts +1 -1
  34. package/src/runtime/log-dedupe.ts +3 -3
  35. package/src/runtime/outbound-ack-timeout.ts +2 -4
  36. package/src/runtime/status-snapshots.ts +2 -2
  37. package/src/runtime/status-worker.ts +2 -6
  38. package/src/core/outbox-text-push-prep.ts +0 -1
  39. package/src/plugin/bridge-file-transfer-push-facade.ts +0 -142
  40. package/src/plugin/outbox-file-push-flow.ts +0 -69
  41. package/src/plugin/outbox-text-push-flow.ts +0 -90
@@ -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,213 @@ 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
+ 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
+ }
@@ -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,
@@ -1,187 +1,4 @@
1
- import { buildBncrDebugJsonMessage } from '../../core/logging.ts';
2
- import type { BncrRoute, OutboxEntry } from '../../core/types.ts';
3
- import { buildReplyMediaFallbackDebugInfo } from './diagnostics.ts';
4
- import type {
5
- EnqueueSingleReplyMediaEntryParams,
6
- NormalizedReplyPayload,
7
- ReplyMediaEntriesParams,
8
- ReplyMediaFallbackTextEntryParams,
9
- ReplyMediaFileTransferParams,
10
- } from './reply-enqueue.ts';
11
- import type { OutboundReplyTargetPolicy } from './reply-target-policy.ts';
12
-
13
- export function enqueueReplyMediaFallbackTextEntry(
14
- params: ReplyMediaFallbackTextEntryParams,
15
- helpers: {
16
- logInfo: (
17
- scope: string | undefined,
18
- message: string,
19
- options?: { debugOnly?: boolean },
20
- ) => void;
21
- enqueueOutbound: (entry: OutboxEntry) => void;
22
- buildTextOutboxEntry: (args: {
23
- accountId: string;
24
- sessionKey: string;
25
- route: BncrRoute;
26
- text: string;
27
- extra?: Record<string, unknown>;
28
- kind?: 'tool' | 'block' | 'final';
29
- replyToId?: string;
30
- replyTargetPolicy?: OutboundReplyTargetPolicy;
31
- }) => OutboxEntry;
32
- },
33
- ): void {
34
- helpers.logInfo(
35
- 'outbound',
36
- buildBncrDebugJsonMessage('media-dedupe-hit', buildReplyMediaFallbackDebugInfo(params)),
37
- { debugOnly: true },
38
- );
39
- helpers.enqueueOutbound(
40
- helpers.buildTextOutboxEntry({
41
- accountId: params.accountId,
42
- sessionKey: params.sessionKey,
43
- route: params.route,
44
- text: params.fallback.text,
45
- kind: params.kind,
46
- replyToId: params.replyToId || undefined,
47
- replyTargetPolicy: params.replyTargetPolicy,
48
- }),
49
- );
50
- }
51
-
52
- export function enqueueReplyMediaFileTransferEntry(
53
- params: ReplyMediaFileTransferParams,
54
- helpers: {
55
- enqueueOutbound: (entry: OutboxEntry) => void;
56
- buildFileTransferOutboxEntry: (args: {
57
- accountId: string;
58
- sessionKey: string;
59
- route: BncrRoute;
60
- mediaUrl: string;
61
- mediaLocalRoots?: readonly string[];
62
- text: string;
63
- asVoice: boolean;
64
- audioAsVoice: boolean;
65
- downloadMedia?: boolean;
66
- type?: string;
67
- extra?: Record<string, unknown>;
68
- kind?: 'tool' | 'block' | 'final';
69
- replyToId?: string;
70
- replyTargetPolicy?: OutboundReplyTargetPolicy;
71
- }) => OutboxEntry;
72
- rememberRecentMediaSend: (args: {
73
- sessionKey: string;
74
- mediaUrl: string;
75
- text: string;
76
- replyToId: string;
77
- createdAt: number;
78
- }) => void;
79
- },
80
- ): void {
81
- helpers.enqueueOutbound(
82
- helpers.buildFileTransferOutboxEntry({
83
- accountId: params.accountId,
84
- sessionKey: params.sessionKey,
85
- route: params.route,
86
- mediaUrl: params.mediaUrl,
87
- mediaLocalRoots: params.mediaLocalRoots,
88
- text: params.text,
89
- asVoice: params.asVoice,
90
- audioAsVoice: params.audioAsVoice,
91
- downloadMedia: params.downloadMedia,
92
- type: params.type,
93
- extra: params.extra,
94
- kind: params.kind,
95
- replyToId: params.replyToId || undefined,
96
- replyTargetPolicy: params.replyTargetPolicy,
97
- }),
98
- );
99
- helpers.rememberRecentMediaSend({
100
- sessionKey: params.sessionKey,
101
- mediaUrl: params.mediaUrl,
102
- text: params.normalizedText,
103
- replyToId: params.replyToId,
104
- createdAt: params.createdAt,
105
- });
106
- }
107
-
108
- export function enqueueSingleReplyMediaEntry(
109
- params: EnqueueSingleReplyMediaEntryParams,
110
- helpers: {
111
- enqueueReplyMediaFallbackTextEntry: (params: ReplyMediaFallbackTextEntryParams) => void;
112
- enqueueReplyMediaFileTransferEntry: (params: ReplyMediaFileTransferParams) => void;
113
- },
114
- ): void {
115
- if (params.fallback !== null) {
116
- helpers.enqueueReplyMediaFallbackTextEntry({
117
- accountId: params.params.accountId,
118
- sessionKey: params.params.sessionKey,
119
- route: params.params.route,
120
- mediaUrl: params.mediaUrl,
121
- kind: params.params.payload.kind,
122
- replyToId: params.params.payload.replyToId,
123
- replyTargetPolicy: params.params.payload.replyTargetPolicy,
124
- fallback: params.fallback,
125
- });
126
- return;
127
- }
128
-
129
- helpers.enqueueReplyMediaFileTransferEntry({
130
- accountId: params.params.accountId,
131
- sessionKey: params.params.sessionKey,
132
- route: params.params.route,
133
- mediaUrl: params.mediaUrl,
134
- mediaLocalRoots: params.params.mediaLocalRoots,
135
- text: params.text,
136
- normalizedText: params.normalizedText,
137
- asVoice: params.params.payload.asVoice,
138
- audioAsVoice: params.params.payload.audioAsVoice,
139
- downloadMedia: params.params.payload.downloadMedia,
140
- type: params.params.payload.type,
141
- extra: params.params.payload.extra,
142
- kind: params.params.payload.kind,
143
- replyToId: params.params.payload.replyToId,
144
- replyTargetPolicy: params.params.payload.replyTargetPolicy,
145
- createdAt: params.currentTime,
146
- });
147
- }
148
-
149
- export function enqueueReplyMediaEntries(
150
- params: ReplyMediaEntriesParams,
151
- helpers: {
152
- now: () => number;
153
- normalizeMessageText: (text: string) => string;
154
- tryBuildMediaDedupeFallback: (args: {
155
- sessionKey: string;
156
- mediaUrl: string;
157
- text: string;
158
- replyToId: string;
159
- currentTime: number;
160
- }) => { text: string; reason: string } | null;
161
- enqueueSingleReplyMediaEntry: (params: EnqueueSingleReplyMediaEntryParams) => void;
162
- },
163
- ): void {
164
- const currentTime = helpers.now();
165
- const normalizedText = helpers.normalizeMessageText(params.payload.text);
166
-
167
- for (const mediaUrl of params.payload.mediaList) {
168
- const fallback = helpers.tryBuildMediaDedupeFallback({
169
- sessionKey: params.sessionKey,
170
- mediaUrl,
171
- text: normalizedText,
172
- replyToId: params.payload.replyToId,
173
- currentTime,
174
- });
175
- helpers.enqueueSingleReplyMediaEntry({
176
- params,
177
- mediaUrl,
178
- normalizedText,
179
- text: params.payload.text,
180
- fallback,
181
- currentTime,
182
- });
183
- }
184
- }
1
+ import type { NormalizedReplyPayload } from './reply-enqueue.ts';
185
2
 
186
3
  export function hasReplyMediaEntries(payload: NormalizedReplyPayload) {
187
4
  return payload.mediaList.length > 0;