@xmoxmo/bncr 0.5.6 → 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 (73) 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 +79 -43
  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 +34 -18
  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/parse.ts +1 -31
  32. package/src/messaging/inbound/scene-admin.ts +8 -8
  33. package/src/messaging/outbound/diagnostics.ts +1 -6
  34. package/src/messaging/outbound/durable-queue-adapter.ts +17 -11
  35. package/src/messaging/outbound/marker-parser.ts +62 -28
  36. package/src/messaging/outbound/media-dedupe.ts +2 -6
  37. package/src/messaging/outbound/media.ts +1 -6
  38. package/src/messaging/outbound/normalize-outbound-send.ts +327 -0
  39. package/src/messaging/outbound/queue-selectors.ts +1 -5
  40. package/src/messaging/outbound/reply-enqueue.ts +121 -70
  41. package/src/messaging/outbound/session-route.ts +6 -11
  42. package/src/messaging/outbound/target-resolver.ts +1 -6
  43. package/src/openclaw/routing-runtime.ts +0 -11
  44. package/src/openclaw/sdk-helpers.ts +0 -2
  45. package/src/plugin/bridge-file-transfer-push-facade.ts +6 -6
  46. package/src/plugin/bridge-media-facade.ts +0 -1
  47. package/src/plugin/channel-components.ts +0 -13
  48. package/src/plugin/channel-plugin-surface-group.ts +52 -65
  49. package/src/plugin/channel-runtime-builders-delivery.ts +1 -0
  50. package/src/plugin/channel-runtime-types.ts +2 -24
  51. package/src/plugin/channel-send.ts +205 -221
  52. package/src/plugin/channel-utils.ts +8 -29
  53. package/src/plugin/connection-handlers-helpers.ts +0 -15
  54. package/src/plugin/connection-handlers.ts +6 -5
  55. package/src/plugin/file-inbound-abort.ts +5 -0
  56. package/src/plugin/file-inbound-chunk.ts +5 -0
  57. package/src/plugin/file-inbound-complete.ts +5 -0
  58. package/src/plugin/media-dedupe-runtime.ts +1 -6
  59. package/src/plugin/message-send.ts +7 -8
  60. package/src/plugin/outbound.ts +6 -26
  61. package/src/plugin/outbox-drain-loop.ts +2 -2
  62. package/src/plugin/outbox-file-push-flow.ts +7 -7
  63. package/src/plugin/outbox-text-push-flow.ts +1 -1
  64. package/src/plugin/target-runtime.ts +1 -6
  65. package/src/runtime/outbound-ack-timeout.ts +1 -8
  66. package/src/runtime/outbox-transitions.ts +1 -4
  67. package/src/runtime/status-worker.ts +1 -9
  68. package/src/messaging/outbound/build-send-action.ts +0 -130
  69. package/src/messaging/outbound/durable-message-adapter.ts +0 -121
  70. package/src/messaging/outbound/send-params.ts +0 -114
  71. package/src/messaging/outbound/send.ts +0 -127
  72. package/src/openclaw/ingress-runtime.ts +0 -25
  73. 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
  }
@@ -1,5 +1,5 @@
1
1
  import type { BncrRoute, OutboxEntry } from '../../core/types.ts';
2
- import { extractConsumptionFields, parseBncrMarker } from './marker-parser.ts';
2
+ import { buildNormalizedSends, normalizeOutboundSend } from './normalize-outbound-send.ts';
3
3
  import { hasReplyMediaEntries } from './reply-enqueue-media.ts';
4
4
  import type { OutboundReplyTargetPolicy } from './reply-target-policy.ts';
5
5
  import { normalizeOutboundReplyToId } from './reply-target-policy.ts';
@@ -21,6 +21,7 @@ export type ReplyPayloadInput = {
21
21
  audioAsVoice?: boolean;
22
22
  downloadMedia?: boolean;
23
23
  type?: string;
24
+ markerHasMsg?: boolean;
24
25
  extra?: Record<string, unknown>;
25
26
  kind?: 'tool' | 'block' | 'final';
26
27
  replyToId?: string;
@@ -35,6 +36,7 @@ export type NormalizedReplyPayload = {
35
36
  audioAsVoice: boolean;
36
37
  downloadMedia?: boolean;
37
38
  type?: string;
39
+ markerHasMsg?: boolean;
38
40
  extra?: Record<string, unknown>;
39
41
  kind?: 'tool' | 'block' | 'final';
40
42
  replyToId: string;
@@ -70,6 +72,7 @@ export type ReplyMediaFileTransferParams = {
70
72
  audioAsVoice: boolean;
71
73
  downloadMedia?: boolean;
72
74
  type?: string;
75
+ markerHasMsg?: boolean;
73
76
  extra?: Record<string, unknown>;
74
77
  kind?: 'tool' | 'block' | 'final';
75
78
  replyToId: string;
@@ -97,7 +100,7 @@ export type ReplyMediaFallbackTextEntryParams = {
97
100
  fallback: { text: string; reason: string };
98
101
  };
99
102
 
100
- export function shouldSplitReplyMediaText(payload: NormalizedReplyPayload) {
103
+ function shouldSplitReplyMediaText(payload: NormalizedReplyPayload) {
101
104
  if (!payload.text) return false;
102
105
  if (payload.mediaList.length > 1) return true;
103
106
  return payload.text.length > MEDIA_TEXT_SPLIT_THRESHOLD;
@@ -115,14 +118,14 @@ export function buildReplyEnqueuePlan(payload: NormalizedReplyPayload): ReplyEnq
115
118
  return { kind: 'media-only', clearText: false };
116
119
  }
117
120
 
118
- export function withoutReplyMediaText(payload: NormalizedReplyPayload): NormalizedReplyPayload {
121
+ function withoutReplyMediaText(payload: NormalizedReplyPayload): NormalizedReplyPayload {
119
122
  return {
120
123
  ...payload,
121
124
  text: '',
122
125
  };
123
126
  }
124
127
 
125
- export function buildReplyTextOutboxEntry(
128
+ function buildReplyTextOutboxEntry(
126
129
  params: {
127
130
  accountId: string;
128
131
  sessionKey: string;
@@ -131,6 +134,7 @@ export function buildReplyTextOutboxEntry(
131
134
  kind?: 'tool' | 'block' | 'final';
132
135
  replyToId: string;
133
136
  replyTargetPolicy: OutboundReplyTargetPolicy;
137
+ markerHasMsg?: boolean;
134
138
  extra?: Record<string, unknown>;
135
139
  },
136
140
  helpers: {
@@ -142,6 +146,7 @@ export function buildReplyTextOutboxEntry(
142
146
  kind?: 'tool' | 'block' | 'final';
143
147
  replyToId?: string;
144
148
  replyTargetPolicy?: OutboundReplyTargetPolicy;
149
+ markerHasMsg?: boolean;
145
150
  extra?: Record<string, unknown>;
146
151
  }) => OutboxEntry;
147
152
  },
@@ -175,6 +180,7 @@ export function enqueueReplyTextEntry(
175
180
  kind?: 'tool' | 'block' | 'final';
176
181
  replyToId?: string;
177
182
  replyTargetPolicy?: OutboundReplyTargetPolicy;
183
+ markerHasMsg?: boolean;
178
184
  extra?: Record<string, unknown>;
179
185
  }) => OutboxEntry;
180
186
  },
@@ -198,15 +204,16 @@ export function enqueueReplyTextEntry(
198
204
  );
199
205
  }
200
206
 
201
- 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(
202
215
  params: EnqueueNormalizedReplyPayloadParams,
203
216
  helpers: {
204
- logEnqueueFromReply: (args: {
205
- accountId: string;
206
- sessionKey: string;
207
- route: BncrRoute;
208
- payload: NormalizedReplyPayload;
209
- }) => void;
210
217
  enqueueReplyMediaEntries: (params: ReplyMediaEntriesParams) => void;
211
218
  enqueueReplyTextEntry: (params: {
212
219
  accountId: string;
@@ -216,49 +223,90 @@ export function enqueueNormalizedReplyPayload(
216
223
  }) => void;
217
224
  },
218
225
  ): void {
219
- helpers.logEnqueueFromReply({
220
- accountId: params.accountId,
221
- sessionKey: params.sessionKey,
222
- route: params.route,
223
- payload: params.payload,
224
- });
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
+ };
225
242
 
226
- const plan = buildReplyEnqueuePlan(params.payload);
243
+ const plan = buildReplyEnqueuePlan(sendPayload);
227
244
 
228
- if (plan.kind === 'text-and-media') {
229
- helpers.enqueueReplyTextEntry({
230
- accountId: params.accountId,
231
- sessionKey: params.sessionKey,
232
- route: params.route,
233
- payload: params.payload,
234
- });
235
- helpers.enqueueReplyMediaEntries({
236
- accountId: params.accountId,
237
- sessionKey: params.sessionKey,
238
- route: params.route,
239
- payload: withoutReplyMediaText(params.payload),
240
- mediaLocalRoots: params.mediaLocalRoots,
241
- });
242
- return;
243
- }
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
+ }
244
272
 
245
- if (plan.kind !== 'text-only') {
246
- helpers.enqueueReplyMediaEntries({
273
+ helpers.enqueueReplyTextEntry({
247
274
  accountId: params.accountId,
248
275
  sessionKey: params.sessionKey,
249
276
  route: params.route,
250
- payload: params.payload,
251
- mediaLocalRoots: params.mediaLocalRoots,
277
+ payload: sendPayload,
252
278
  });
253
- return;
254
279
  }
280
+ }
255
281
 
256
- 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({
257
302
  accountId: params.accountId,
258
303
  sessionKey: params.sessionKey,
259
304
  route: params.route,
260
305
  payload: params.payload,
261
306
  });
307
+
308
+ // Dispatch ALL entries (pre-send + main) through the same path.
309
+ dispatchNormalizedSends(params, helpers);
262
310
  }
263
311
 
264
312
  export function normalizeReplyPayload(
@@ -266,40 +314,43 @@ export function normalizeReplyPayload(
266
314
  helpers: { asString: (value: unknown, fallback?: string) => string },
267
315
  options?: { replyTargetPolicy?: OutboundReplyTargetPolicy },
268
316
  ): NormalizedReplyPayload {
269
- const rawText = helpers.asString(payload?.text || '').trim();
270
- // Parse [BncrParam:...] marker from reply text
271
- const { cleanText, params: markerParams } = parseBncrMarker(rawText);
272
- const text = cleanText;
273
- const mediaUrl = helpers.asString(payload?.mediaUrl || '').trim();
274
- const mediaUrls = Array.isArray(payload?.mediaUrls)
275
- ? payload.mediaUrls.map((v) => helpers.asString(v || '').trim()).filter(Boolean)
276
- : undefined;
277
- const type = helpers.asString(payload?.type || '').trim();
278
- // Merge marker params with payload extra (marker wins), strip consumption fields
279
- const mergedExtra: Record<string, unknown> = {
280
- ...(payload?.extra ? { ...payload.extra } : {}),
281
- ...markerParams,
282
- };
283
- const { remaining } = extractConsumptionFields(
284
- Object.keys(mergedExtra).length > 0 ? mergedExtra : undefined,
285
- );
286
- const finalExtra = Object.keys(remaining).length > 0 ? remaining : undefined;
287
- return {
288
- text,
289
- mediaUrl,
290
- mediaUrls,
291
- 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,
292
321
  asVoice: payload?.asVoice === true,
293
322
  audioAsVoice: payload?.audioAsVoice === true,
294
- downloadMedia: payload?.downloadMedia === true ? true : undefined,
295
- ...(type ? { type } : {}),
296
- ...(finalExtra ? { extra: finalExtra } : {}),
323
+ downloadMedia: payload?.downloadMedia,
324
+ type: helpers.asString(payload?.type || '').trim() || undefined,
297
325
  kind: payload?.kind,
298
- 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,
299
350
  replyToId: normalizeOutboundReplyToId({
300
- kind: payload?.kind,
301
- replyToId: payload?.replyToId,
302
- replyTargetPolicy: options?.replyTargetPolicy,
351
+ kind,
352
+ replyToId: normalized.replyToId ?? payload?.replyToId,
353
+ replyTargetPolicy,
303
354
  }),
304
355
  };
305
356
  }
@@ -7,6 +7,7 @@ import {
7
7
  routeScopeToHex,
8
8
  } from '../../core/targets.ts';
9
9
  import type { BncrRoute } from '../../core/types.ts';
10
+ import { asString } from '../../core/value-sanitize.ts';
10
11
  import { buildOpenClawChannelOutboundSessionRoute } from '../../openclaw/session-route-runtime.ts';
11
12
  import type { BncrChannelConfigRoot } from '../../plugin/channel-runtime-types.ts';
12
13
 
@@ -18,7 +19,7 @@ type BncrChannelRouteRefFields = {
18
19
  rawTo: string;
19
20
  chatType: 'direct' | 'group';
20
21
  };
21
- thread?: { id: string };
22
+ thread?: { id: string | number };
22
23
  };
23
24
 
24
25
  type BncrOutboundSessionRoute = ChannelOutboundSessionRoute & BncrChannelRouteRefFields;
@@ -30,24 +31,18 @@ type ResolveBncrOutboundSessionRouteParams = {
30
31
  accountId?: string;
31
32
  target: string;
32
33
  resolvedTarget?: { to?: string } | null;
33
- threadId?: string;
34
+ threadId?: string | number | null;
34
35
  canonicalAgentId: string;
35
36
  resolveRouteBySession?: (raw: string, accountId: string) => BncrRoute | null;
36
37
  };
37
38
 
38
- function asString(v: unknown, fallback = ''): string {
39
- if (typeof v === 'string') return v;
40
- if (v == null) return fallback;
41
- return String(v);
42
- }
43
-
44
39
  function attachBncrChannelRouteRefFields(args: {
45
40
  built: ChannelOutboundSessionRoute;
46
41
  channel: string;
47
42
  accountId?: string;
48
43
  to: string;
49
44
  chatType: 'direct' | 'group';
50
- threadId?: string;
45
+ threadId?: string | number | null;
51
46
  }): BncrOutboundSessionRoute {
52
47
  const { built, channel, accountId, to, chatType, threadId } = args;
53
48
  return {
@@ -59,7 +54,7 @@ function attachBncrChannelRouteRefFields(args: {
59
54
  rawTo: to,
60
55
  chatType,
61
56
  },
62
- ...(threadId !== undefined ? { thread: { id: threadId } } : {}),
57
+ ...(threadId != null ? { thread: { id: threadId } } : {}),
63
58
  };
64
59
  }
65
60
 
@@ -99,7 +94,7 @@ export function resolveBncrOutboundSessionRoute(params: ResolveBncrOutboundSessi
99
94
  chatType: 'direct',
100
95
  from: displayTo,
101
96
  to: displayTo,
102
- ...(params.threadId !== undefined ? { threadId: params.threadId } : {}),
97
+ ...(params.threadId != null ? { threadId: params.threadId } : {}),
103
98
  });
104
99
 
105
100
  return attachBncrChannelRouteRefFields({