@xmoxmo/bncr 0.5.7 → 0.5.9

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 (44) hide show
  1. package/package.json +1 -1
  2. package/scripts/check-pack.mjs +0 -2
  3. package/scripts/selfcheck.mjs +48 -13
  4. package/src/bootstrap/runtime-loader.ts +1 -1
  5. package/src/channel.ts +50 -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 +234 -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/ack-outbox-runtime-group.ts +2 -0
  22. package/src/plugin/bridge-media-facade.ts +0 -3
  23. package/src/plugin/bridge-outbox-facade.ts +14 -2
  24. package/src/plugin/channel-runtime-builders-delivery.ts +2 -362
  25. package/src/plugin/channel-runtime-builders-status.ts +0 -160
  26. package/src/plugin/channel-runtime-builders.ts +3 -17
  27. package/src/plugin/channel-runtime-constants.ts +1 -1
  28. package/src/plugin/channel-send.ts +5 -4
  29. package/src/plugin/file-transfer-orchestrator.ts +39 -2
  30. package/src/plugin/media-orchestrators-runtime-group.ts +20 -149
  31. package/src/plugin/messaging.ts +3 -3
  32. package/src/plugin/outbox-drain-failure.ts +34 -1
  33. package/src/plugin/outbox-drain-runtime.ts +3 -0
  34. package/src/plugin/outbox-push.ts +1 -52
  35. package/src/plugin/outbox-unified-push-flow.ts +307 -0
  36. package/src/plugin/scene-registry.ts +1 -1
  37. package/src/runtime/log-dedupe.ts +3 -3
  38. package/src/runtime/outbound-ack-timeout.ts +2 -4
  39. package/src/runtime/status-snapshots.ts +2 -2
  40. package/src/runtime/status-worker.ts +2 -6
  41. package/src/core/outbox-text-push-prep.ts +0 -1
  42. package/src/plugin/bridge-file-transfer-push-facade.ts +0 -142
  43. package/src/plugin/outbox-file-push-flow.ts +0 -69
  44. package/src/plugin/outbox-text-push-flow.ts +0 -90
@@ -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;
@@ -1,11 +1,34 @@
1
+ import { buildBncrDebugJsonMessage } from '../../core/logging.ts';
1
2
  import type { BncrRoute, OutboxEntry } from '../../core/types.ts';
2
- import { buildNormalizedSends, normalizeOutboundSend } from './normalize-outbound-send.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 }
@@ -43,14 +66,6 @@ export type NormalizedReplyPayload = {
43
66
  replyTargetPolicy: OutboundReplyTargetPolicy;
44
67
  };
45
68
 
46
- export type ReplyMediaEntriesParams = {
47
- accountId: string;
48
- sessionKey: string;
49
- route: BncrRoute;
50
- payload: NormalizedReplyPayload;
51
- mediaLocalRoots?: readonly string[];
52
- };
53
-
54
69
  export type EnqueueNormalizedReplyPayloadParams = {
55
70
  accountId: string;
56
71
  sessionKey: string;
@@ -60,46 +75,6 @@ export type EnqueueNormalizedReplyPayloadParams = {
60
75
  replyToId?: string;
61
76
  };
62
77
 
63
- export type ReplyMediaFileTransferParams = {
64
- accountId: string;
65
- sessionKey: string;
66
- route: BncrRoute;
67
- mediaUrl: string;
68
- mediaLocalRoots?: readonly string[];
69
- text: string;
70
- normalizedText: string;
71
- asVoice: boolean;
72
- audioAsVoice: boolean;
73
- downloadMedia?: boolean;
74
- type?: string;
75
- markerHasMsg?: boolean;
76
- extra?: Record<string, unknown>;
77
- kind?: 'tool' | 'block' | 'final';
78
- replyToId: string;
79
- replyTargetPolicy: OutboundReplyTargetPolicy;
80
- createdAt: number;
81
- };
82
-
83
- export type EnqueueSingleReplyMediaEntryParams = {
84
- params: ReplyMediaEntriesParams;
85
- mediaUrl: string;
86
- normalizedText: string;
87
- text: string;
88
- fallback: { text: string; reason: string } | null;
89
- currentTime: number;
90
- };
91
-
92
- export type ReplyMediaFallbackTextEntryParams = {
93
- accountId: string;
94
- sessionKey: string;
95
- route: BncrRoute;
96
- mediaUrl: string;
97
- kind?: 'tool' | 'block' | 'final';
98
- replyToId: string;
99
- replyTargetPolicy: OutboundReplyTargetPolicy;
100
- fallback: { text: string; reason: string };
101
- };
102
-
103
78
  function shouldSplitReplyMediaText(payload: NormalizedReplyPayload) {
104
79
  if (!payload.text) return false;
105
80
  if (payload.mediaList.length > 1) return true;
@@ -118,51 +93,9 @@ export function buildReplyEnqueuePlan(payload: NormalizedReplyPayload): ReplyEnq
118
93
  return { kind: 'media-only', clearText: false };
119
94
  }
120
95
 
121
- function withoutReplyMediaText(payload: NormalizedReplyPayload): NormalizedReplyPayload {
122
- return {
123
- ...payload,
124
- text: '',
125
- };
126
- }
127
-
128
- function buildReplyTextOutboxEntry(
129
- params: {
130
- accountId: string;
131
- sessionKey: string;
132
- route: BncrRoute;
133
- text: string;
134
- kind?: 'tool' | 'block' | 'final';
135
- replyToId: string;
136
- replyTargetPolicy: OutboundReplyTargetPolicy;
137
- markerHasMsg?: boolean;
138
- extra?: Record<string, unknown>;
139
- },
140
- helpers: {
141
- buildTextOutboxEntry: (args: {
142
- accountId: string;
143
- sessionKey: string;
144
- route: BncrRoute;
145
- text: string;
146
- kind?: 'tool' | 'block' | 'final';
147
- replyToId?: string;
148
- replyTargetPolicy?: OutboundReplyTargetPolicy;
149
- markerHasMsg?: boolean;
150
- extra?: Record<string, unknown>;
151
- }) => OutboxEntry;
152
- },
153
- ): OutboxEntry {
154
- return helpers.buildTextOutboxEntry({
155
- accountId: params.accountId,
156
- sessionKey: params.sessionKey,
157
- route: params.route,
158
- text: params.text,
159
- kind: params.kind,
160
- replyToId: params.replyToId || undefined,
161
- replyTargetPolicy: params.replyTargetPolicy,
162
- extra: params.extra,
163
- });
164
- }
165
-
96
+ /**
97
+ * @deprecated Only used by native-reply-delivery.test.mjs. Not called from production code.
98
+ */
166
99
  export function enqueueReplyTextEntry(
167
100
  params: {
168
101
  accountId: string;
@@ -172,35 +105,34 @@ export function enqueueReplyTextEntry(
172
105
  },
173
106
  helpers: {
174
107
  enqueueOutbound: (entry: OutboxEntry) => void;
175
- buildTextOutboxEntry: (args: {
108
+ buildOutboxEntry: (args: {
176
109
  accountId: string;
177
110
  sessionKey: string;
178
111
  route: BncrRoute;
179
- text: string;
112
+ type?: string;
113
+ msg: string;
180
114
  kind?: 'tool' | 'block' | 'final';
181
115
  replyToId?: string;
182
116
  replyTargetPolicy?: OutboundReplyTargetPolicy;
183
- markerHasMsg?: boolean;
184
117
  extra?: Record<string, unknown>;
118
+ transferMode?: 'text' | 'media';
185
119
  }) => OutboxEntry;
186
120
  },
187
121
  ): void {
188
122
  if (!params.payload.text && !params.payload.extra) return;
189
123
 
190
124
  helpers.enqueueOutbound(
191
- buildReplyTextOutboxEntry(
192
- {
193
- accountId: params.accountId,
194
- sessionKey: params.sessionKey,
195
- route: params.route,
196
- text: params.payload.text,
197
- extra: params.payload.extra,
198
- kind: params.payload.kind,
199
- replyToId: params.payload.replyToId,
200
- replyTargetPolicy: params.payload.replyTargetPolicy,
201
- },
202
- { buildTextOutboxEntry: helpers.buildTextOutboxEntry },
203
- ),
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
+ }),
204
136
  );
205
137
  }
206
138
 
@@ -211,71 +143,209 @@ export function enqueueReplyTextEntry(
211
143
  * main payload). Every entry goes through `buildReplyEnqueuePlan` → the same
212
144
  * text/media dispatch. There is no separate pre-send or main-send branch.
213
145
  */
214
- function dispatchNormalizedSends(
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(
215
174
  params: EnqueueNormalizedReplyPayloadParams,
216
175
  helpers: {
217
- enqueueReplyMediaEntries: (params: ReplyMediaEntriesParams) => void;
218
- enqueueReplyTextEntry: (params: {
219
- accountId: string;
176
+ enqueueOutbound: (entry: OutboxEntry) => void;
177
+ buildOutboxEntry: BuildOutboxEntryFn;
178
+ tryBuildMediaDedupeFallback: (args: {
220
179
  sessionKey: string;
221
- route: BncrRoute;
222
- payload: NormalizedReplyPayload;
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;
223
194
  }) => void;
195
+ logInfo: (
196
+ scope: string | undefined,
197
+ message: string,
198
+ options?: { debugOnly?: boolean },
199
+ ) => void;
224
200
  },
225
201
  ): void {
226
202
  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
- };
242
-
203
+ const sendPayload = buildSendPayload(entry, params.payload);
243
204
  const plan = buildReplyEnqueuePlan(sendPayload);
244
205
 
245
206
  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
- });
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);
259
222
  continue;
260
223
  }
261
224
 
262
225
  if (plan.kind !== 'text-only') {
263
- helpers.enqueueReplyMediaEntries({
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({
264
235
  accountId: params.accountId,
265
236
  sessionKey: params.sessionKey,
266
237
  route: params.route,
267
- payload: sendPayload,
268
- mediaLocalRoots: params.mediaLocalRoots,
269
- });
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,
254
+ helpers: {
255
+ enqueueOutbound: (entry: OutboxEntry) => void;
256
+ buildOutboxEntry: BuildOutboxEntryFn;
257
+ tryBuildMediaDedupeFallback: (args: {
258
+ sessionKey: string;
259
+ mediaUrl: string;
260
+ text: string;
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;
279
+ },
280
+ ): void {
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
+ });
293
+
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;
270
319
  continue;
271
320
  }
272
321
 
273
- helpers.enqueueReplyTextEntry({
274
- accountId: params.accountId,
322
+ helpers.enqueueOutbound(
323
+ helpers.buildOutboxEntry({
324
+ accountId: params.accountId,
325
+ sessionKey: params.sessionKey,
326
+ route: params.route,
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({
275
342
  sessionKey: params.sessionKey,
276
- route: params.route,
277
- payload: sendPayload,
343
+ mediaUrl,
344
+ text: normalizedText,
345
+ replyToId: sendPayload.replyToId,
346
+ createdAt: currentTime,
278
347
  });
348
+ first = false;
279
349
  }
280
350
  }
281
351
 
@@ -288,13 +358,30 @@ export function enqueueNormalizedReplyPayload(
288
358
  route: BncrRoute;
289
359
  payload: NormalizedReplyPayload;
290
360
  }) => void;
291
- enqueueReplyMediaEntries: (params: ReplyMediaEntriesParams) => void;
292
- enqueueReplyTextEntry: (params: {
293
- accountId: string;
361
+ enqueueOutbound: (entry: OutboxEntry) => void;
362
+ buildOutboxEntry: BuildOutboxEntryFn;
363
+ tryBuildMediaDedupeFallback: (args: {
294
364
  sessionKey: string;
295
- route: BncrRoute;
296
- 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;
297
379
  }) => void;
380
+ logInfo: (
381
+ scope: string | undefined,
382
+ message: string,
383
+ options?: { debugOnly?: boolean },
384
+ ) => void;
298
385
  },
299
386
  ): void {
300
387
  // Log only the main (last) send entry for traceability.
@@ -306,15 +393,15 @@ export function enqueueNormalizedReplyPayload(
306
393
  });
307
394
 
308
395
  // Dispatch ALL entries (pre-send + main) through the same path.
309
- dispatchNormalizedSends(params, helpers);
396
+ enqueueUnifiedOutbound(params, helpers);
310
397
  }
311
398
 
312
- export function normalizeReplyPayload(
399
+ export async function normalizeReplyPayload(
313
400
  payload: ReplyPayloadInput,
314
401
  helpers: { asString: (value: unknown, fallback?: string) => string },
315
402
  options?: { replyTargetPolicy?: OutboundReplyTargetPolicy },
316
- ): NormalizedReplyPayload {
317
- const normalized = normalizeOutboundSend({
403
+ ): Promise<NormalizedReplyPayload> {
404
+ const normalized = await normalizeOutboundSend({
318
405
  text: helpers.asString(payload?.text || ''),
319
406
  mediaUrl: helpers.asString(payload?.mediaUrl || ''),
320
407
  mediaUrls: payload?.mediaUrls,