@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
@@ -1,11 +1,34 @@
1
+ import { buildBncrDebugJsonMessage } from '../../core/logging.ts';
1
2
  import type { BncrRoute, OutboxEntry } from '../../core/types.ts';
2
- import { extractConsumptionFields, parseBncrMarker } from './marker-parser.ts';
3
+ import {
4
+ buildNormalizedSends,
5
+ type NormalizedOutboundSend,
6
+ normalizeOutboundSend,
7
+ } from './normalize-outbound-send.ts';
3
8
  import { hasReplyMediaEntries } from './reply-enqueue-media.ts';
4
9
  import type { OutboundReplyTargetPolicy } from './reply-target-policy.ts';
5
10
  import { normalizeOutboundReplyToId } from './reply-target-policy.ts';
6
11
 
7
12
  const MEDIA_TEXT_SPLIT_THRESHOLD = 1020;
8
13
 
14
+ type BuildOutboxEntryFn = (args: {
15
+ accountId: string;
16
+ sessionKey: string;
17
+ route: BncrRoute;
18
+ type?: string;
19
+ msg: string;
20
+ kind?: 'tool' | 'block' | 'final';
21
+ replyToId?: string;
22
+ replyTargetPolicy?: OutboundReplyTargetPolicy;
23
+ extra?: Record<string, unknown>;
24
+ transferMode?: 'text' | 'media';
25
+ mediaUrl?: string;
26
+ mediaLocalRoots?: readonly string[];
27
+ asVoice?: boolean;
28
+ audioAsVoice?: boolean;
29
+ downloadMedia?: boolean;
30
+ }) => OutboxEntry;
31
+
9
32
  export type ReplyEnqueuePlan =
10
33
  | { kind: 'text-only' }
11
34
  | { kind: 'media-only'; clearText: false }
@@ -21,6 +44,7 @@ export type ReplyPayloadInput = {
21
44
  audioAsVoice?: boolean;
22
45
  downloadMedia?: boolean;
23
46
  type?: string;
47
+ markerHasMsg?: boolean;
24
48
  extra?: Record<string, unknown>;
25
49
  kind?: 'tool' | 'block' | 'final';
26
50
  replyToId?: string;
@@ -35,20 +59,13 @@ export type NormalizedReplyPayload = {
35
59
  audioAsVoice: boolean;
36
60
  downloadMedia?: boolean;
37
61
  type?: string;
62
+ markerHasMsg?: boolean;
38
63
  extra?: Record<string, unknown>;
39
64
  kind?: 'tool' | 'block' | 'final';
40
65
  replyToId: string;
41
66
  replyTargetPolicy: OutboundReplyTargetPolicy;
42
67
  };
43
68
 
44
- export type ReplyMediaEntriesParams = {
45
- accountId: string;
46
- sessionKey: string;
47
- route: BncrRoute;
48
- payload: NormalizedReplyPayload;
49
- mediaLocalRoots?: readonly string[];
50
- };
51
-
52
69
  export type EnqueueNormalizedReplyPayloadParams = {
53
70
  accountId: string;
54
71
  sessionKey: string;
@@ -58,46 +75,7 @@ export type EnqueueNormalizedReplyPayloadParams = {
58
75
  replyToId?: string;
59
76
  };
60
77
 
61
- export type ReplyMediaFileTransferParams = {
62
- accountId: string;
63
- sessionKey: string;
64
- route: BncrRoute;
65
- mediaUrl: string;
66
- mediaLocalRoots?: readonly string[];
67
- text: string;
68
- normalizedText: string;
69
- asVoice: boolean;
70
- audioAsVoice: boolean;
71
- downloadMedia?: boolean;
72
- type?: string;
73
- extra?: Record<string, unknown>;
74
- kind?: 'tool' | 'block' | 'final';
75
- replyToId: string;
76
- replyTargetPolicy: OutboundReplyTargetPolicy;
77
- createdAt: number;
78
- };
79
-
80
- export type EnqueueSingleReplyMediaEntryParams = {
81
- params: ReplyMediaEntriesParams;
82
- mediaUrl: string;
83
- normalizedText: string;
84
- text: string;
85
- fallback: { text: string; reason: string } | null;
86
- currentTime: number;
87
- };
88
-
89
- export type ReplyMediaFallbackTextEntryParams = {
90
- accountId: string;
91
- sessionKey: string;
92
- route: BncrRoute;
93
- mediaUrl: string;
94
- kind?: 'tool' | 'block' | 'final';
95
- replyToId: string;
96
- replyTargetPolicy: OutboundReplyTargetPolicy;
97
- fallback: { text: string; reason: string };
98
- };
99
-
100
- export function shouldSplitReplyMediaText(payload: NormalizedReplyPayload) {
78
+ function shouldSplitReplyMediaText(payload: NormalizedReplyPayload) {
101
79
  if (!payload.text) return false;
102
80
  if (payload.mediaList.length > 1) return true;
103
81
  return payload.text.length > MEDIA_TEXT_SPLIT_THRESHOLD;
@@ -115,87 +93,260 @@ export function buildReplyEnqueuePlan(payload: NormalizedReplyPayload): ReplyEnq
115
93
  return { kind: 'media-only', clearText: false };
116
94
  }
117
95
 
118
- export function withoutReplyMediaText(payload: NormalizedReplyPayload): NormalizedReplyPayload {
119
- return {
120
- ...payload,
121
- text: '',
122
- };
123
- }
124
-
125
- export function buildReplyTextOutboxEntry(
96
+ /**
97
+ * @deprecated Only used by native-reply-delivery.test.mjs. Not called from production code.
98
+ */
99
+ export function enqueueReplyTextEntry(
126
100
  params: {
127
101
  accountId: string;
128
102
  sessionKey: string;
129
103
  route: BncrRoute;
130
- text: string;
131
- kind?: 'tool' | 'block' | 'final';
132
- replyToId: string;
133
- replyTargetPolicy: OutboundReplyTargetPolicy;
134
- extra?: Record<string, unknown>;
104
+ payload: NormalizedReplyPayload;
135
105
  },
136
106
  helpers: {
137
- buildTextOutboxEntry: (args: {
107
+ enqueueOutbound: (entry: OutboxEntry) => void;
108
+ buildOutboxEntry: (args: {
138
109
  accountId: string;
139
110
  sessionKey: string;
140
111
  route: BncrRoute;
141
- text: string;
112
+ type?: string;
113
+ msg: string;
142
114
  kind?: 'tool' | 'block' | 'final';
143
115
  replyToId?: string;
144
116
  replyTargetPolicy?: OutboundReplyTargetPolicy;
145
117
  extra?: Record<string, unknown>;
118
+ transferMode?: 'text' | 'media';
146
119
  }) => OutboxEntry;
147
120
  },
148
- ): OutboxEntry {
149
- return helpers.buildTextOutboxEntry({
150
- accountId: params.accountId,
151
- sessionKey: params.sessionKey,
152
- route: params.route,
153
- text: params.text,
154
- kind: params.kind,
155
- replyToId: params.replyToId || undefined,
156
- replyTargetPolicy: params.replyTargetPolicy,
157
- extra: params.extra,
158
- });
121
+ ): void {
122
+ if (!params.payload.text && !params.payload.extra) return;
123
+
124
+ helpers.enqueueOutbound(
125
+ helpers.buildOutboxEntry({
126
+ accountId: params.accountId,
127
+ sessionKey: params.sessionKey,
128
+ route: params.route,
129
+ type: 'text',
130
+ msg: params.payload.text,
131
+ extra: params.payload.extra,
132
+ kind: params.payload.kind,
133
+ replyToId: params.payload.replyToId,
134
+ replyTargetPolicy: params.payload.replyTargetPolicy,
135
+ }),
136
+ );
159
137
  }
160
138
 
161
- export function enqueueReplyTextEntry(
162
- params: {
163
- accountId: string;
164
- sessionKey: string;
165
- route: BncrRoute;
166
- payload: NormalizedReplyPayload;
139
+ /**
140
+ * Dispatch all entries from the normalisation plan through a unified path.
141
+ *
142
+ * `buildNormalizedSends` returns one or more sends (pre-send text-only +
143
+ * main payload). Every entry goes through `buildReplyEnqueuePlan` → the same
144
+ * text/media dispatch. There is no separate pre-send or main-send branch.
145
+ */
146
+ function buildSendPayload(
147
+ entry: NormalizedOutboundSend,
148
+ parent: NormalizedReplyPayload,
149
+ ): NormalizedReplyPayload {
150
+ return {
151
+ text: entry.text,
152
+ mediaUrl: entry.mediaUrl || '',
153
+ mediaUrls: entry.mediaUrls,
154
+ mediaList: entry.mediaUrl ? [entry.mediaUrl] : entry.mediaUrls?.length ? entry.mediaUrls : [],
155
+ asVoice: entry.asVoice,
156
+ audioAsVoice: entry.audioAsVoice,
157
+ downloadMedia: entry.downloadMedia,
158
+ type: entry.type,
159
+ extra: entry.extra,
160
+ kind: entry.kind,
161
+ replyToId: entry.replyToId ?? '',
162
+ replyTargetPolicy: parent.replyTargetPolicy,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Unified outbound dispatch: single exit point for all sends.
168
+ *
169
+ * Iterates `buildNormalizedSends` entries and dispatches each through
170
+ * the existing text or media helpers internally. External callers only
171
+ * see one unified function — they do not choose the branch.
172
+ */
173
+ function enqueueUnifiedOutbound(
174
+ params: EnqueueNormalizedReplyPayloadParams,
175
+ helpers: {
176
+ enqueueOutbound: (entry: OutboxEntry) => void;
177
+ buildOutboxEntry: BuildOutboxEntryFn;
178
+ tryBuildMediaDedupeFallback: (args: {
179
+ sessionKey: string;
180
+ mediaUrl: string;
181
+ text: string;
182
+ replyToId: string;
183
+ currentTime: number;
184
+ }) => {
185
+ text: string;
186
+ reason: 'same-text-sent-checkmark' | 'text-changed-downgrade';
187
+ } | null;
188
+ rememberRecentMediaSend: (args: {
189
+ sessionKey: string;
190
+ mediaUrl: string;
191
+ text: string;
192
+ replyToId: string;
193
+ createdAt: number;
194
+ }) => void;
195
+ logInfo: (
196
+ scope: string | undefined,
197
+ message: string,
198
+ options?: { debugOnly?: boolean },
199
+ ) => void;
167
200
  },
201
+ ): void {
202
+ for (const entry of buildNormalizedSends(params.payload)) {
203
+ const sendPayload = buildSendPayload(entry, params.payload);
204
+ const plan = buildReplyEnqueuePlan(sendPayload);
205
+
206
+ if (plan.kind === 'text-and-media') {
207
+ // Text first, then media entries with empty text
208
+ helpers.enqueueOutbound(
209
+ helpers.buildOutboxEntry({
210
+ accountId: params.accountId,
211
+ sessionKey: params.sessionKey,
212
+ route: params.route,
213
+ type: 'text',
214
+ msg: sendPayload.text,
215
+ kind: sendPayload.kind,
216
+ replyToId: sendPayload.replyToId || undefined,
217
+ replyTargetPolicy: sendPayload.replyTargetPolicy,
218
+ extra: sendPayload.extra,
219
+ }),
220
+ );
221
+ enqueueUnifiedMediaEntries(params, sendPayload, '', helpers);
222
+ continue;
223
+ }
224
+
225
+ if (plan.kind !== 'text-only') {
226
+ // Media-only: first entry carries caption text
227
+ enqueueUnifiedMediaEntries(params, sendPayload, sendPayload.text, helpers);
228
+ continue;
229
+ }
230
+
231
+ // Text-only
232
+ if (!sendPayload.text && !sendPayload.extra) continue;
233
+ helpers.enqueueOutbound(
234
+ helpers.buildOutboxEntry({
235
+ accountId: params.accountId,
236
+ sessionKey: params.sessionKey,
237
+ route: params.route,
238
+ type: 'text',
239
+ msg: sendPayload.text,
240
+ kind: sendPayload.kind,
241
+ replyToId: sendPayload.replyToId || undefined,
242
+ replyTargetPolicy: sendPayload.replyTargetPolicy,
243
+ extra: sendPayload.extra,
244
+ }),
245
+ );
246
+ }
247
+ }
248
+
249
+ /** Internal: iterate mediaList and enqueue file-transfer entries (with dedup). */
250
+ function enqueueUnifiedMediaEntries(
251
+ params: EnqueueNormalizedReplyPayloadParams,
252
+ sendPayload: NormalizedReplyPayload,
253
+ firstCaption: string,
168
254
  helpers: {
169
255
  enqueueOutbound: (entry: OutboxEntry) => void;
170
- buildTextOutboxEntry: (args: {
171
- accountId: string;
256
+ buildOutboxEntry: BuildOutboxEntryFn;
257
+ tryBuildMediaDedupeFallback: (args: {
172
258
  sessionKey: string;
173
- route: BncrRoute;
259
+ mediaUrl: string;
174
260
  text: string;
175
- kind?: 'tool' | 'block' | 'final';
176
- replyToId?: string;
177
- replyTargetPolicy?: OutboundReplyTargetPolicy;
178
- extra?: Record<string, unknown>;
179
- }) => OutboxEntry;
261
+ replyToId: string;
262
+ currentTime: number;
263
+ }) => {
264
+ text: string;
265
+ reason: 'same-text-sent-checkmark' | 'text-changed-downgrade';
266
+ } | null;
267
+ rememberRecentMediaSend: (args: {
268
+ sessionKey: string;
269
+ mediaUrl: string;
270
+ text: string;
271
+ replyToId: string;
272
+ createdAt: number;
273
+ }) => void;
274
+ logInfo: (
275
+ scope: string | undefined,
276
+ message: string,
277
+ options?: { debugOnly?: boolean },
278
+ ) => void;
180
279
  },
181
280
  ): void {
182
- if (!params.payload.text && !params.payload.extra) return;
281
+ let first = true;
282
+ for (const mediaUrl of sendPayload.mediaList) {
283
+ const text = first ? firstCaption : '';
284
+ const currentTime = Date.now();
285
+ const normalizedText = text || '';
286
+ const fallback = helpers.tryBuildMediaDedupeFallback({
287
+ sessionKey: params.sessionKey,
288
+ mediaUrl,
289
+ text: normalizedText,
290
+ replyToId: sendPayload.replyToId,
291
+ currentTime,
292
+ });
183
293
 
184
- helpers.enqueueOutbound(
185
- buildReplyTextOutboxEntry(
186
- {
294
+ if (fallback) {
295
+ helpers.logInfo(
296
+ 'outbound',
297
+ buildBncrDebugJsonMessage('media-dedupe-hit', {
298
+ sessionKey: params.sessionKey,
299
+ mediaUrl,
300
+ text: normalizedText,
301
+ replyToId: sendPayload.replyToId,
302
+ reason: fallback.reason,
303
+ }),
304
+ { debugOnly: true },
305
+ );
306
+ helpers.enqueueOutbound(
307
+ helpers.buildOutboxEntry({
308
+ accountId: params.accountId,
309
+ sessionKey: params.sessionKey,
310
+ route: params.route,
311
+ type: 'text',
312
+ msg: fallback.text,
313
+ kind: sendPayload.kind,
314
+ replyToId: sendPayload.replyToId || undefined,
315
+ replyTargetPolicy: sendPayload.replyTargetPolicy,
316
+ }),
317
+ );
318
+ first = false;
319
+ continue;
320
+ }
321
+
322
+ helpers.enqueueOutbound(
323
+ helpers.buildOutboxEntry({
187
324
  accountId: params.accountId,
188
325
  sessionKey: params.sessionKey,
189
326
  route: params.route,
190
- text: params.payload.text,
191
- extra: params.payload.extra,
192
- kind: params.payload.kind,
193
- replyToId: params.payload.replyToId,
194
- replyTargetPolicy: params.payload.replyTargetPolicy,
195
- },
196
- { buildTextOutboxEntry: helpers.buildTextOutboxEntry },
197
- ),
198
- );
327
+ type: sendPayload.type,
328
+ msg: text,
329
+ kind: sendPayload.kind,
330
+ replyToId: sendPayload.replyToId,
331
+ replyTargetPolicy: sendPayload.replyTargetPolicy,
332
+ extra: sendPayload.extra,
333
+ transferMode: 'media',
334
+ mediaUrl,
335
+ mediaLocalRoots: params.mediaLocalRoots,
336
+ asVoice: sendPayload.asVoice,
337
+ audioAsVoice: sendPayload.audioAsVoice,
338
+ downloadMedia: sendPayload.downloadMedia,
339
+ }),
340
+ );
341
+ helpers.rememberRecentMediaSend({
342
+ sessionKey: params.sessionKey,
343
+ mediaUrl,
344
+ text: normalizedText,
345
+ replyToId: sendPayload.replyToId,
346
+ createdAt: currentTime,
347
+ });
348
+ first = false;
349
+ }
199
350
  }
200
351
 
201
352
  export function enqueueNormalizedReplyPayload(
@@ -207,15 +358,33 @@ export function enqueueNormalizedReplyPayload(
207
358
  route: BncrRoute;
208
359
  payload: NormalizedReplyPayload;
209
360
  }) => void;
210
- enqueueReplyMediaEntries: (params: ReplyMediaEntriesParams) => void;
211
- enqueueReplyTextEntry: (params: {
212
- accountId: string;
361
+ enqueueOutbound: (entry: OutboxEntry) => void;
362
+ buildOutboxEntry: BuildOutboxEntryFn;
363
+ tryBuildMediaDedupeFallback: (args: {
213
364
  sessionKey: string;
214
- route: BncrRoute;
215
- payload: NormalizedReplyPayload;
365
+ mediaUrl: string;
366
+ text: string;
367
+ replyToId: string;
368
+ currentTime: number;
369
+ }) => {
370
+ text: string;
371
+ reason: 'same-text-sent-checkmark' | 'text-changed-downgrade';
372
+ } | null;
373
+ rememberRecentMediaSend: (args: {
374
+ sessionKey: string;
375
+ mediaUrl: string;
376
+ text: string;
377
+ replyToId: string;
378
+ createdAt: number;
216
379
  }) => void;
380
+ logInfo: (
381
+ scope: string | undefined,
382
+ message: string,
383
+ options?: { debugOnly?: boolean },
384
+ ) => void;
217
385
  },
218
386
  ): void {
387
+ // Log only the main (last) send entry for traceability.
219
388
  helpers.logEnqueueFromReply({
220
389
  accountId: params.accountId,
221
390
  sessionKey: params.sessionKey,
@@ -223,83 +392,52 @@ export function enqueueNormalizedReplyPayload(
223
392
  payload: params.payload,
224
393
  });
225
394
 
226
- const plan = buildReplyEnqueuePlan(params.payload);
227
-
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
- }
244
-
245
- if (plan.kind !== 'text-only') {
246
- helpers.enqueueReplyMediaEntries({
247
- accountId: params.accountId,
248
- sessionKey: params.sessionKey,
249
- route: params.route,
250
- payload: params.payload,
251
- mediaLocalRoots: params.mediaLocalRoots,
252
- });
253
- return;
254
- }
255
-
256
- helpers.enqueueReplyTextEntry({
257
- accountId: params.accountId,
258
- sessionKey: params.sessionKey,
259
- route: params.route,
260
- payload: params.payload,
261
- });
395
+ // Dispatch ALL entries (pre-send + main) through the same path.
396
+ enqueueUnifiedOutbound(params, helpers);
262
397
  }
263
398
 
264
- export function normalizeReplyPayload(
399
+ export async function normalizeReplyPayload(
265
400
  payload: ReplyPayloadInput,
266
401
  helpers: { asString: (value: unknown, fallback?: string) => string },
267
402
  options?: { replyTargetPolicy?: OutboundReplyTargetPolicy },
268
- ): 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] : [],
403
+ ): Promise<NormalizedReplyPayload> {
404
+ const normalized = await normalizeOutboundSend({
405
+ text: helpers.asString(payload?.text || ''),
406
+ mediaUrl: helpers.asString(payload?.mediaUrl || ''),
407
+ mediaUrls: payload?.mediaUrls,
292
408
  asVoice: payload?.asVoice === true,
293
409
  audioAsVoice: payload?.audioAsVoice === true,
294
- downloadMedia: payload?.downloadMedia === true ? true : undefined,
295
- ...(type ? { type } : {}),
296
- ...(finalExtra ? { extra: finalExtra } : {}),
410
+ downloadMedia: payload?.downloadMedia,
411
+ type: helpers.asString(payload?.type || '').trim() || undefined,
297
412
  kind: payload?.kind,
298
- replyTargetPolicy: options?.replyTargetPolicy ?? 'agent-default',
413
+ replyToId: payload?.replyToId,
414
+ extra: payload?.extra,
415
+ });
416
+
417
+ const markerHasMsg = normalized.markerHasMsg === true;
418
+ const mediaUrl = helpers.asString(normalized.mediaUrl || '');
419
+ const mediaUrls = normalized.mediaUrls;
420
+ const mediaList = mediaUrls?.length ? mediaUrls : mediaUrl ? [mediaUrl] : [];
421
+ const kind = normalized.kind;
422
+ const replyTargetPolicy = options?.replyTargetPolicy ?? 'agent-default';
423
+
424
+ return {
425
+ text: normalized.text,
426
+ mediaUrl,
427
+ mediaUrls,
428
+ mediaList,
429
+ asVoice: normalized.asVoice,
430
+ audioAsVoice: normalized.audioAsVoice,
431
+ downloadMedia: normalized.downloadMedia,
432
+ ...(normalized.type ? { type: normalized.type } : {}),
433
+ ...(normalized.extra ? { extra: normalized.extra } : {}),
434
+ markerHasMsg,
435
+ kind,
436
+ replyTargetPolicy,
299
437
  replyToId: normalizeOutboundReplyToId({
300
- kind: payload?.kind,
301
- replyToId: payload?.replyToId,
302
- replyTargetPolicy: options?.replyTargetPolicy,
438
+ kind,
439
+ replyToId: normalized.replyToId ?? payload?.replyToId,
440
+ replyTargetPolicy,
303
441
  }),
304
442
  };
305
443
  }
@@ -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({
@@ -4,6 +4,7 @@ import {
4
4
  parseStrictBncrSessionKey,
5
5
  } from '../../core/targets.ts';
6
6
  import type { BncrRoute } from '../../core/types.ts';
7
+ import { asString } from '../../core/value-sanitize.ts';
7
8
 
8
9
  type ResolveBncrOutboundTargetParams = {
9
10
  target: string;
@@ -11,12 +12,6 @@ type ResolveBncrOutboundTargetParams = {
11
12
  resolveRouteBySession?: (raw: string, accountId: string) => BncrRoute | null;
12
13
  };
13
14
 
14
- function asString(v: unknown, fallback = ''): string {
15
- if (typeof v === 'string') return v;
16
- if (v == null) return fallback;
17
- return String(v);
18
- }
19
-
20
15
  export function looksLikeBncrExplicitTarget(input: string): boolean {
21
16
  const raw = asString(input).trim();
22
17
  if (!raw) return false;
@@ -48,7 +48,7 @@ export function isOpenClawRemoteHttpMediaUrl(mediaUrl: string): boolean {
48
48
  * Returns the first absolute path that exists on disk, or the original
49
49
  * relative path if nothing is found (the host will then emit its own error).
50
50
  */
51
- export function resolveRelativeMediaPath(mediaUrl: string, localRoots?: readonly string[]): string {
51
+ function resolveRelativeMediaPath(mediaUrl: string, localRoots?: readonly string[]): string {
52
52
  if (!mediaUrl || !localRoots?.length) return mediaUrl;
53
53
  if (path.isAbsolute(mediaUrl)) return mediaUrl;
54
54
  // HTTP / file:// / data: / ~ paths are handled elsewhere