@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
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Unified outbound send normalization.
3
+ *
4
+ * All send entry points (tool action, channel.send, channel.message)
5
+ * fold into one shape first, then branch media vs text.
6
+ *
7
+ * Priority for control fields: marker/text-block > host/params extra > direct params.
8
+ * Marker-specified type is always preserved (in normalized.type or re-injected into extra
9
+ * for text branch) and never silently downgraded.
10
+ *
11
+ * Marker path/paths are media evidence unless ismedia is explicitly false.
12
+ */
13
+
14
+ import { asString, isPlainObject } from '../../core/value-sanitize.ts';
15
+ import { extractConsumptionFields, parseBncrMarker } from './marker-parser.ts';
16
+
17
+ const MEDIA_SOURCE_EXTRA_KEYS = ['path', 'paths', 'mediaUrl', 'mediaUrls'] as const;
18
+
19
+ export type UnifiedOutboundSendInput = {
20
+ /** Primary text body (channel text / tool message). */
21
+ text?: string;
22
+ message?: string;
23
+ caption?: string;
24
+ mediaUrl?: string;
25
+ media?: string;
26
+ path?: string;
27
+ filePath?: string;
28
+ mediaUrls?: unknown;
29
+ asVoice: boolean;
30
+ audioAsVoice: boolean;
31
+ downloadMedia?: boolean;
32
+ type?: string;
33
+ kind?: string;
34
+ replyToId?: string;
35
+ replyToMessageId?: string;
36
+ extra?: Record<string, unknown>;
37
+ forceDocument?: boolean;
38
+ gifPlayback?: boolean;
39
+ silent?: boolean;
40
+ };
41
+
42
+ export type NormalizedOutboundSend = {
43
+ markerHasMsg?: boolean;
44
+ text: string;
45
+ mediaUrl?: string;
46
+ mediaUrls?: string[];
47
+ asVoice: boolean;
48
+ audioAsVoice: boolean;
49
+ downloadMedia?: boolean;
50
+ type?: string;
51
+ hasMedia?: boolean;
52
+ extra?: Record<string, unknown>;
53
+ kind?: 'tool' | 'block' | 'final';
54
+ replyToId?: string;
55
+ };
56
+
57
+ function asTrimmedStringList(value: unknown): string[] | undefined {
58
+ if (!Array.isArray(value)) return undefined;
59
+ const list = value.map((v) => (typeof v === 'string' ? v.trim() : '')).filter(Boolean);
60
+ return list.length > 0 ? list : undefined;
61
+ }
62
+
63
+ function normalizeReplyKind(value: unknown): 'tool' | 'block' | 'final' | undefined {
64
+ return value === 'tool' || value === 'block' || value === 'final' ? value : undefined;
65
+ }
66
+
67
+ /** Strip media-source keys so they cannot override resolved media frame fields. */
68
+ function stripMediaSourceFieldsFromExtra(
69
+ extra: Record<string, unknown> | undefined,
70
+ ): Record<string, unknown> | undefined {
71
+ if (!extra) return undefined;
72
+ const next: Record<string, unknown> = { ...extra };
73
+ for (const key of MEDIA_SOURCE_EXTRA_KEYS) {
74
+ delete next[key];
75
+ }
76
+ return Object.keys(next).length > 0 ? next : undefined;
77
+ }
78
+
79
+ /**
80
+ * Build extra for the chosen branch.
81
+ * - media: strip media-source keys (path already resolved into mediaUrl)
82
+ * - text: re-inject consumed media type for adapter passthrough when present
83
+ */
84
+ function buildBranchExtra(args: {
85
+ hasMedia?: boolean;
86
+ extra?: Record<string, unknown>;
87
+ consumedType?: string;
88
+ }): Record<string, unknown> | undefined {
89
+ if (args.hasMedia) {
90
+ return stripMediaSourceFieldsFromExtra(args.extra);
91
+ }
92
+ const textExtra =
93
+ args.consumedType && args.extra
94
+ ? { ...args.extra, type: args.consumedType }
95
+ : args.consumedType
96
+ ? { type: args.consumedType }
97
+ : args.extra
98
+ ? { ...args.extra }
99
+ : undefined;
100
+ return textExtra && Object.keys(textExtra).length > 0 ? textExtra : undefined;
101
+ }
102
+
103
+ /**
104
+ * Parse markers from one or more text fields and merge extras.
105
+ * Marker params always override host/params extra and host control flags.
106
+ */
107
+ export function mergeMarkerAndHostFields(args: {
108
+ texts: string[];
109
+ hostExtra?: Record<string, unknown>;
110
+ forceDocument?: boolean;
111
+ gifPlayback?: boolean;
112
+ silent?: boolean;
113
+ }): {
114
+ cleanTexts: string[];
115
+ markerParams: Record<string, unknown>;
116
+ consumed: Partial<{
117
+ asVoice: boolean;
118
+ audioAsVoice: boolean;
119
+ type: string;
120
+ kind: string;
121
+ replyToId: string;
122
+ downloadMedia: boolean;
123
+ ismedia: boolean;
124
+ }>;
125
+ remaining: Record<string, unknown>;
126
+ markerHasMsg: boolean;
127
+ } {
128
+ const markerParams: Record<string, unknown> = {};
129
+ const cleanTexts: string[] = [];
130
+
131
+ for (const raw of args.texts) {
132
+ const { cleanText, params } = parseBncrMarker(raw);
133
+ cleanTexts.push(cleanText);
134
+ Object.assign(markerParams, params);
135
+ }
136
+
137
+ const merged: Record<string, unknown> = {
138
+ ...(isPlainObject(args.hostExtra) ? { ...args.hostExtra } : {}),
139
+ ...markerParams,
140
+ };
141
+ if (args.forceDocument === true) merged.forceDocument = true;
142
+ if (args.gifPlayback === true) merged.gifPlayback = true;
143
+ if (args.silent === true) merged.silent = true;
144
+
145
+ // Marker wins over host force* when marker also set them (already via Object.assign order)
146
+
147
+ const { consumed, remaining } = extractConsumptionFields(
148
+ Object.keys(merged).length > 0 ? merged : undefined,
149
+ );
150
+
151
+ return {
152
+ cleanTexts,
153
+ markerParams,
154
+ consumed,
155
+ remaining,
156
+ markerHasMsg: typeof markerParams.msg === 'string',
157
+ };
158
+ }
159
+
160
+ function resolveHostMediaUrl(input: UnifiedOutboundSendInput): string {
161
+ return (
162
+ (typeof input.media === 'string' ? input.media : '') ||
163
+ (typeof input.path === 'string' ? input.path : '') ||
164
+ (typeof input.filePath === 'string' ? input.filePath : '') ||
165
+ (typeof input.mediaUrl === 'string' ? input.mediaUrl : '')
166
+ );
167
+ }
168
+
169
+ /**
170
+ * Resolve media sources after marker merge.
171
+ * Host mediaUrl/mediaUrls first; marker path/paths next.
172
+ * Marker path/paths are media evidence unless ismedia is explicitly false.
173
+ */
174
+ function resolveDispatchMediaSources(args: {
175
+ hostMediaUrl?: string;
176
+ hostMediaUrls?: unknown;
177
+ remainingExtra: Record<string, unknown>;
178
+ consumedType?: string;
179
+ /** Tri-state: true=force, false=skip (ismedia explicitly false), undefined=default (path is media evidence). */
180
+ forceMedia?: boolean;
181
+ }): {
182
+ mediaUrl?: string;
183
+ mediaUrls?: string[];
184
+ hasMedia?: boolean;
185
+ } {
186
+ const hostMediaUrl = asString(args.hostMediaUrl || '').trim();
187
+ const markerPath = asString(
188
+ (args.remainingExtra.path as string) || (args.remainingExtra.mediaUrl as string) || '',
189
+ ).trim();
190
+ const markerPaths =
191
+ asTrimmedStringList(args.remainingExtra.paths) ||
192
+ asTrimmedStringList(args.remainingExtra.mediaUrls);
193
+ const useMarkerPath = Boolean(markerPath) && args.forceMedia !== false;
194
+
195
+ const singleFromHost = hostMediaUrl || undefined;
196
+ const multiFromHost = asTrimmedStringList(args.hostMediaUrls);
197
+ const mediaUrls = multiFromHost || markerPaths;
198
+ let mediaUrl = singleFromHost || (useMarkerPath ? markerPath : undefined);
199
+
200
+ // If mediaUrl is already in mediaUrls, drop single to avoid double-send.
201
+ if (mediaUrl && mediaUrls?.includes(mediaUrl)) {
202
+ mediaUrl = undefined;
203
+ } else if (mediaUrl && mediaUrls?.length) {
204
+ // Keep both: single + multi as mediaUrls list with single first
205
+ const merged = [mediaUrl, ...mediaUrls.filter((u) => u !== mediaUrl)];
206
+ return {
207
+ mediaUrl: undefined,
208
+ mediaUrls: merged,
209
+ hasMedia: true,
210
+ };
211
+ }
212
+
213
+ const hasMedia = Boolean(mediaUrl || mediaUrls?.length) || args.forceMedia === true;
214
+ return {
215
+ mediaUrl,
216
+ mediaUrls,
217
+ hasMedia,
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Normalize any outbound send input into a single dispatch shape.
223
+ * Callers only need to branch on `hasMedia` after this.
224
+ */
225
+ export function normalizeOutboundSend(input: UnifiedOutboundSendInput): NormalizedOutboundSend {
226
+ const rawText = asString(input.text ?? input.message ?? '', '');
227
+ const rawCaption = asString(input.caption ?? '', '');
228
+ // Prefer text/message; also parse caption so marker in either field works.
229
+ const textsToParse = rawCaption && rawCaption !== rawText ? [rawText, rawCaption] : [rawText];
230
+
231
+ const { cleanTexts, consumed, remaining, markerHasMsg } = mergeMarkerAndHostFields({
232
+ texts: textsToParse,
233
+ hostExtra: isPlainObject(input.extra) ? input.extra : undefined,
234
+ forceDocument: input.forceDocument,
235
+ gifPlayback: input.gifPlayback,
236
+ silent: input.silent,
237
+ });
238
+
239
+ const cleanText = cleanTexts[0] || '';
240
+ const cleanCaption = cleanTexts.length > 1 ? cleanTexts[1] || '' : '';
241
+
242
+ const forceMedia = consumed.ismedia;
243
+ const consumedType = typeof consumed.type === 'string' ? consumed.type : undefined;
244
+ const hostType = asString(input.type || '').trim() || undefined;
245
+
246
+ const media = resolveDispatchMediaSources({
247
+ hostMediaUrl: resolveHostMediaUrl(input),
248
+ hostMediaUrls: input.mediaUrls,
249
+ remainingExtra: remaining,
250
+ consumedType,
251
+ forceMedia,
252
+ });
253
+
254
+ const asVoice = typeof consumed.asVoice === 'boolean' ? consumed.asVoice : input.asVoice === true;
255
+ const audioAsVoice =
256
+ typeof consumed.audioAsVoice === 'boolean'
257
+ ? consumed.audioAsVoice
258
+ : input.audioAsVoice === true;
259
+ // Tri-state: marker boolean wins; otherwise host value; else undefined (scene cascade).
260
+ const downloadMedia =
261
+ typeof consumed.downloadMedia === 'boolean' ? consumed.downloadMedia : input.downloadMedia;
262
+ const type = consumedType ?? hostType;
263
+
264
+ // For media sends: text becomes caption (prefer caption field when both exist).
265
+ // For text sends: prefer message/text, fall back to caption.
266
+ const body = media.hasMedia ? cleanCaption || cleanText : cleanText || cleanCaption;
267
+
268
+ const kind = normalizeReplyKind(consumed.kind) ?? normalizeReplyKind(input.kind) ?? undefined;
269
+ const replyToId =
270
+ asString(consumed.replyToId || input.replyToId || input.replyToMessageId || '').trim() ||
271
+ undefined;
272
+
273
+ const extra = buildBranchExtra({
274
+ hasMedia: media.hasMedia,
275
+ extra: Object.keys(remaining).length > 0 ? remaining : undefined,
276
+ consumedType,
277
+ });
278
+
279
+ return {
280
+ text: body,
281
+ markerHasMsg,
282
+ mediaUrl: media.mediaUrl,
283
+ mediaUrls: media.mediaUrls,
284
+ asVoice,
285
+ audioAsVoice,
286
+ ...(downloadMedia !== undefined ? { downloadMedia } : {}),
287
+ ...(type ? { type } : {}),
288
+ hasMedia: media.hasMedia,
289
+ ...(extra ? { extra } : {}),
290
+ ...(kind ? { kind } : {}),
291
+ ...(replyToId ? { replyToId } : {}),
292
+ };
293
+ }
294
+
295
+ /**
296
+ * Given an already-normalized output, produce the full list of sends.
297
+ *
298
+ * When the marker supplies a `msg` field that will override the clean text
299
+ * body (non-empty `text` different from `extra.msg`), the list includes
300
+ * the clean text as a standalone text-only entry first, followed by the
301
+ * main payload. Otherwise the list contains just the single entry.
302
+ */
303
+ export function buildNormalizedSends(normalized: NormalizedOutboundSend): NormalizedOutboundSend[] {
304
+ if (
305
+ normalized.markerHasMsg &&
306
+ normalized.text &&
307
+ typeof normalized.extra?.msg === 'string' &&
308
+ normalized.extra.msg !== normalized.text
309
+ ) {
310
+ return [
311
+ // Pre-send: clean text only, no media, no extra
312
+ {
313
+ text: normalized.text,
314
+ markerHasMsg: true,
315
+ hasMedia: false,
316
+ asVoice: false,
317
+ audioAsVoice: false,
318
+ kind: normalized.kind,
319
+ replyToId: normalized.replyToId,
320
+ },
321
+ // Main send: as-is (adapter uses extra.msg as body)
322
+ normalized,
323
+ ];
324
+ }
325
+
326
+ return [normalized];
327
+ }
@@ -1,4 +1,5 @@
1
1
  import type { BncrConnection, OutboxEntry } from '../../core/types.ts';
2
+ import { finiteNumberOr } from '../../core/value-sanitize.ts';
2
3
 
3
4
  export type OutboxRouteSelection = {
4
5
  connIds: string[];
@@ -25,11 +26,6 @@ export type OutboxFileTransferRouteSelection = {
25
26
  ownerConnId?: string;
26
27
  };
27
28
 
28
- function finiteNumberOr(value: unknown, fallback: number): number {
29
- const n = Number(value);
30
- return Number.isFinite(n) ? n : fallback;
31
- }
32
-
33
29
  export function computeOutboxRetryWait(nextAttemptAt: number, nowMs: number): number {
34
30
  return Math.max(0, finiteNumberOr(nextAttemptAt, 0) - finiteNumberOr(nowMs, 0));
35
31
  }
@@ -24,6 +24,7 @@ export function enqueueReplyMediaFallbackTextEntry(
24
24
  sessionKey: string;
25
25
  route: BncrRoute;
26
26
  text: string;
27
+ extra?: Record<string, unknown>;
27
28
  kind?: 'tool' | 'block' | 'final';
28
29
  replyToId?: string;
29
30
  replyTargetPolicy?: OutboundReplyTargetPolicy;
@@ -61,6 +62,7 @@ export function enqueueReplyMediaFileTransferEntry(
61
62
  text: string;
62
63
  asVoice: boolean;
63
64
  audioAsVoice: boolean;
65
+ downloadMedia?: boolean;
64
66
  type?: string;
65
67
  extra?: Record<string, unknown>;
66
68
  kind?: 'tool' | 'block' | 'final';
@@ -86,6 +88,7 @@ export function enqueueReplyMediaFileTransferEntry(
86
88
  text: params.text,
87
89
  asVoice: params.asVoice,
88
90
  audioAsVoice: params.audioAsVoice,
91
+ downloadMedia: params.downloadMedia,
89
92
  type: params.type,
90
93
  extra: params.extra,
91
94
  kind: params.kind,
@@ -133,6 +136,7 @@ export function enqueueSingleReplyMediaEntry(
133
136
  normalizedText: params.normalizedText,
134
137
  asVoice: params.params.payload.asVoice,
135
138
  audioAsVoice: params.params.payload.audioAsVoice,
139
+ downloadMedia: params.params.payload.downloadMedia,
136
140
  type: params.params.payload.type,
137
141
  extra: params.params.payload.extra,
138
142
  kind: params.params.payload.kind,
@@ -1,4 +1,5 @@
1
1
  import type { BncrRoute, OutboxEntry } from '../../core/types.ts';
2
+ import { buildNormalizedSends, normalizeOutboundSend } from './normalize-outbound-send.ts';
2
3
  import { hasReplyMediaEntries } from './reply-enqueue-media.ts';
3
4
  import type { OutboundReplyTargetPolicy } from './reply-target-policy.ts';
4
5
  import { normalizeOutboundReplyToId } from './reply-target-policy.ts';
@@ -18,7 +19,9 @@ export type ReplyPayloadInput = {
18
19
  mediaUrls?: string[];
19
20
  asVoice?: boolean;
20
21
  audioAsVoice?: boolean;
22
+ downloadMedia?: boolean;
21
23
  type?: string;
24
+ markerHasMsg?: boolean;
22
25
  extra?: Record<string, unknown>;
23
26
  kind?: 'tool' | 'block' | 'final';
24
27
  replyToId?: string;
@@ -31,7 +34,9 @@ export type NormalizedReplyPayload = {
31
34
  mediaList: string[];
32
35
  asVoice: boolean;
33
36
  audioAsVoice: boolean;
37
+ downloadMedia?: boolean;
34
38
  type?: string;
39
+ markerHasMsg?: boolean;
35
40
  extra?: Record<string, unknown>;
36
41
  kind?: 'tool' | 'block' | 'final';
37
42
  replyToId: string;
@@ -65,7 +70,9 @@ export type ReplyMediaFileTransferParams = {
65
70
  normalizedText: string;
66
71
  asVoice: boolean;
67
72
  audioAsVoice: boolean;
73
+ downloadMedia?: boolean;
68
74
  type?: string;
75
+ markerHasMsg?: boolean;
69
76
  extra?: Record<string, unknown>;
70
77
  kind?: 'tool' | 'block' | 'final';
71
78
  replyToId: string;
@@ -93,7 +100,7 @@ export type ReplyMediaFallbackTextEntryParams = {
93
100
  fallback: { text: string; reason: string };
94
101
  };
95
102
 
96
- export function shouldSplitReplyMediaText(payload: NormalizedReplyPayload) {
103
+ function shouldSplitReplyMediaText(payload: NormalizedReplyPayload) {
97
104
  if (!payload.text) return false;
98
105
  if (payload.mediaList.length > 1) return true;
99
106
  return payload.text.length > MEDIA_TEXT_SPLIT_THRESHOLD;
@@ -111,14 +118,14 @@ export function buildReplyEnqueuePlan(payload: NormalizedReplyPayload): ReplyEnq
111
118
  return { kind: 'media-only', clearText: false };
112
119
  }
113
120
 
114
- export function withoutReplyMediaText(payload: NormalizedReplyPayload): NormalizedReplyPayload {
121
+ function withoutReplyMediaText(payload: NormalizedReplyPayload): NormalizedReplyPayload {
115
122
  return {
116
123
  ...payload,
117
124
  text: '',
118
125
  };
119
126
  }
120
127
 
121
- export function buildReplyTextOutboxEntry(
128
+ function buildReplyTextOutboxEntry(
122
129
  params: {
123
130
  accountId: string;
124
131
  sessionKey: string;
@@ -127,6 +134,8 @@ export function buildReplyTextOutboxEntry(
127
134
  kind?: 'tool' | 'block' | 'final';
128
135
  replyToId: string;
129
136
  replyTargetPolicy: OutboundReplyTargetPolicy;
137
+ markerHasMsg?: boolean;
138
+ extra?: Record<string, unknown>;
130
139
  },
131
140
  helpers: {
132
141
  buildTextOutboxEntry: (args: {
@@ -137,6 +146,8 @@ export function buildReplyTextOutboxEntry(
137
146
  kind?: 'tool' | 'block' | 'final';
138
147
  replyToId?: string;
139
148
  replyTargetPolicy?: OutboundReplyTargetPolicy;
149
+ markerHasMsg?: boolean;
150
+ extra?: Record<string, unknown>;
140
151
  }) => OutboxEntry;
141
152
  },
142
153
  ): OutboxEntry {
@@ -148,6 +159,7 @@ export function buildReplyTextOutboxEntry(
148
159
  kind: params.kind,
149
160
  replyToId: params.replyToId || undefined,
150
161
  replyTargetPolicy: params.replyTargetPolicy,
162
+ extra: params.extra,
151
163
  });
152
164
  }
153
165
 
@@ -168,10 +180,12 @@ export function enqueueReplyTextEntry(
168
180
  kind?: 'tool' | 'block' | 'final';
169
181
  replyToId?: string;
170
182
  replyTargetPolicy?: OutboundReplyTargetPolicy;
183
+ markerHasMsg?: boolean;
184
+ extra?: Record<string, unknown>;
171
185
  }) => OutboxEntry;
172
186
  },
173
187
  ): void {
174
- if (!params.payload.text) return;
188
+ if (!params.payload.text && !params.payload.extra) return;
175
189
 
176
190
  helpers.enqueueOutbound(
177
191
  buildReplyTextOutboxEntry(
@@ -180,6 +194,7 @@ export function enqueueReplyTextEntry(
180
194
  sessionKey: params.sessionKey,
181
195
  route: params.route,
182
196
  text: params.payload.text,
197
+ extra: params.payload.extra,
183
198
  kind: params.payload.kind,
184
199
  replyToId: params.payload.replyToId,
185
200
  replyTargetPolicy: params.payload.replyTargetPolicy,
@@ -189,15 +204,16 @@ export function enqueueReplyTextEntry(
189
204
  );
190
205
  }
191
206
 
192
- export function enqueueNormalizedReplyPayload(
207
+ /**
208
+ * Dispatch all entries from the normalisation plan through a unified path.
209
+ *
210
+ * `buildNormalizedSends` returns one or more sends (pre-send text-only +
211
+ * main payload). Every entry goes through `buildReplyEnqueuePlan` → the same
212
+ * text/media dispatch. There is no separate pre-send or main-send branch.
213
+ */
214
+ function dispatchNormalizedSends(
193
215
  params: EnqueueNormalizedReplyPayloadParams,
194
216
  helpers: {
195
- logEnqueueFromReply: (args: {
196
- accountId: string;
197
- sessionKey: string;
198
- route: BncrRoute;
199
- payload: NormalizedReplyPayload;
200
- }) => void;
201
217
  enqueueReplyMediaEntries: (params: ReplyMediaEntriesParams) => void;
202
218
  enqueueReplyTextEntry: (params: {
203
219
  accountId: string;
@@ -207,49 +223,90 @@ export function enqueueNormalizedReplyPayload(
207
223
  }) => void;
208
224
  },
209
225
  ): void {
210
- helpers.logEnqueueFromReply({
211
- accountId: params.accountId,
212
- sessionKey: params.sessionKey,
213
- route: params.route,
214
- payload: params.payload,
215
- });
226
+ for (const entry of buildNormalizedSends(params.payload)) {
227
+ // Build a NormalizedReplyPayload from the plan entry.
228
+ const sendPayload: NormalizedReplyPayload = {
229
+ text: entry.text,
230
+ mediaUrl: entry.mediaUrl || '',
231
+ mediaUrls: entry.mediaUrls,
232
+ mediaList: entry.mediaUrl ? [entry.mediaUrl] : entry.mediaUrls?.length ? entry.mediaUrls : [],
233
+ asVoice: entry.asVoice,
234
+ audioAsVoice: entry.audioAsVoice,
235
+ downloadMedia: entry.downloadMedia,
236
+ type: entry.type,
237
+ extra: entry.extra,
238
+ kind: entry.kind,
239
+ replyToId: entry.replyToId ?? '',
240
+ replyTargetPolicy: params.payload.replyTargetPolicy,
241
+ };
216
242
 
217
- const plan = buildReplyEnqueuePlan(params.payload);
243
+ const plan = buildReplyEnqueuePlan(sendPayload);
218
244
 
219
- if (plan.kind === 'text-and-media') {
220
- helpers.enqueueReplyTextEntry({
221
- accountId: params.accountId,
222
- sessionKey: params.sessionKey,
223
- route: params.route,
224
- payload: params.payload,
225
- });
226
- helpers.enqueueReplyMediaEntries({
227
- accountId: params.accountId,
228
- sessionKey: params.sessionKey,
229
- route: params.route,
230
- payload: withoutReplyMediaText(params.payload),
231
- mediaLocalRoots: params.mediaLocalRoots,
232
- });
233
- return;
234
- }
245
+ if (plan.kind === 'text-and-media') {
246
+ helpers.enqueueReplyTextEntry({
247
+ accountId: params.accountId,
248
+ sessionKey: params.sessionKey,
249
+ route: params.route,
250
+ payload: sendPayload,
251
+ });
252
+ helpers.enqueueReplyMediaEntries({
253
+ accountId: params.accountId,
254
+ sessionKey: params.sessionKey,
255
+ route: params.route,
256
+ payload: withoutReplyMediaText(sendPayload),
257
+ mediaLocalRoots: params.mediaLocalRoots,
258
+ });
259
+ continue;
260
+ }
261
+
262
+ if (plan.kind !== 'text-only') {
263
+ helpers.enqueueReplyMediaEntries({
264
+ accountId: params.accountId,
265
+ sessionKey: params.sessionKey,
266
+ route: params.route,
267
+ payload: sendPayload,
268
+ mediaLocalRoots: params.mediaLocalRoots,
269
+ });
270
+ continue;
271
+ }
235
272
 
236
- if (plan.kind !== 'text-only') {
237
- helpers.enqueueReplyMediaEntries({
273
+ helpers.enqueueReplyTextEntry({
238
274
  accountId: params.accountId,
239
275
  sessionKey: params.sessionKey,
240
276
  route: params.route,
241
- payload: params.payload,
242
- mediaLocalRoots: params.mediaLocalRoots,
277
+ payload: sendPayload,
243
278
  });
244
- return;
245
279
  }
280
+ }
246
281
 
247
- helpers.enqueueReplyTextEntry({
282
+ export function enqueueNormalizedReplyPayload(
283
+ params: EnqueueNormalizedReplyPayloadParams,
284
+ helpers: {
285
+ logEnqueueFromReply: (args: {
286
+ accountId: string;
287
+ sessionKey: string;
288
+ route: BncrRoute;
289
+ payload: NormalizedReplyPayload;
290
+ }) => void;
291
+ enqueueReplyMediaEntries: (params: ReplyMediaEntriesParams) => void;
292
+ enqueueReplyTextEntry: (params: {
293
+ accountId: string;
294
+ sessionKey: string;
295
+ route: BncrRoute;
296
+ payload: NormalizedReplyPayload;
297
+ }) => void;
298
+ },
299
+ ): void {
300
+ // Log only the main (last) send entry for traceability.
301
+ helpers.logEnqueueFromReply({
248
302
  accountId: params.accountId,
249
303
  sessionKey: params.sessionKey,
250
304
  route: params.route,
251
305
  payload: params.payload,
252
306
  });
307
+
308
+ // Dispatch ALL entries (pre-send + main) through the same path.
309
+ dispatchNormalizedSends(params, helpers);
253
310
  }
254
311
 
255
312
  export function normalizeReplyPayload(
@@ -257,27 +314,43 @@ export function normalizeReplyPayload(
257
314
  helpers: { asString: (value: unknown, fallback?: string) => string },
258
315
  options?: { replyTargetPolicy?: OutboundReplyTargetPolicy },
259
316
  ): NormalizedReplyPayload {
260
- const text = helpers.asString(payload?.text || '').trim();
261
- const mediaUrl = helpers.asString(payload?.mediaUrl || '').trim();
262
- const mediaUrls = Array.isArray(payload?.mediaUrls)
263
- ? payload.mediaUrls.map((v) => helpers.asString(v || '').trim()).filter(Boolean)
264
- : undefined;
265
- const type = helpers.asString(payload?.type || '').trim();
266
- return {
267
- text,
268
- mediaUrl,
269
- mediaUrls,
270
- mediaList: mediaUrls?.length ? mediaUrls : mediaUrl ? [mediaUrl] : [],
317
+ const normalized = normalizeOutboundSend({
318
+ text: helpers.asString(payload?.text || ''),
319
+ mediaUrl: helpers.asString(payload?.mediaUrl || ''),
320
+ mediaUrls: payload?.mediaUrls,
271
321
  asVoice: payload?.asVoice === true,
272
322
  audioAsVoice: payload?.audioAsVoice === true,
273
- ...(type ? { type } : {}),
274
- ...(payload?.extra ? { extra: { ...payload.extra } } : {}),
323
+ downloadMedia: payload?.downloadMedia,
324
+ type: helpers.asString(payload?.type || '').trim() || undefined,
275
325
  kind: payload?.kind,
276
- replyTargetPolicy: options?.replyTargetPolicy ?? 'agent-default',
326
+ replyToId: payload?.replyToId,
327
+ extra: payload?.extra,
328
+ });
329
+
330
+ const markerHasMsg = normalized.markerHasMsg === true;
331
+ const mediaUrl = helpers.asString(normalized.mediaUrl || '');
332
+ const mediaUrls = normalized.mediaUrls;
333
+ const mediaList = mediaUrls?.length ? mediaUrls : mediaUrl ? [mediaUrl] : [];
334
+ const kind = normalized.kind;
335
+ const replyTargetPolicy = options?.replyTargetPolicy ?? 'agent-default';
336
+
337
+ return {
338
+ text: normalized.text,
339
+ mediaUrl,
340
+ mediaUrls,
341
+ mediaList,
342
+ asVoice: normalized.asVoice,
343
+ audioAsVoice: normalized.audioAsVoice,
344
+ downloadMedia: normalized.downloadMedia,
345
+ ...(normalized.type ? { type: normalized.type } : {}),
346
+ ...(normalized.extra ? { extra: normalized.extra } : {}),
347
+ markerHasMsg,
348
+ kind,
349
+ replyTargetPolicy,
277
350
  replyToId: normalizeOutboundReplyToId({
278
- kind: payload?.kind,
279
- replyToId: payload?.replyToId,
280
- replyTargetPolicy: options?.replyTargetPolicy,
351
+ kind,
352
+ replyToId: normalized.replyToId ?? payload?.replyToId,
353
+ replyTargetPolicy,
281
354
  }),
282
355
  };
283
356
  }