@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.
- package/dist/index.js +1 -9
- package/index.ts +1 -9
- package/package.json +1 -1
- package/scripts/check-pack.mjs +0 -6
- package/scripts/selfcheck.mjs +0 -7
- package/src/channel.ts +114 -37
- package/src/core/accounts.ts +1 -6
- package/src/core/connection-reachability.ts +1 -5
- package/src/core/dead-letter-diagnostics.ts +4 -10
- package/src/core/outbox-entry-builders.ts +38 -15
- package/src/core/outbox-file-transfer-failure.ts +5 -0
- package/src/core/outbox-file-transfer-guards.ts +6 -0
- package/src/core/outbox-file-transfer-prep.ts +8 -5
- package/src/core/outbox-file-transfer-success.ts +5 -0
- package/src/core/outbox-queue.ts +1 -7
- package/src/core/outbox-summary.ts +15 -27
- package/src/core/outbox-text-push-prep.ts +1 -36
- package/src/core/permissions.ts +1 -9
- package/src/core/persisted-outbox-entry.ts +54 -11
- package/src/core/policy.ts +1 -11
- package/src/core/register-trace.ts +2 -5
- package/src/core/status-meta.ts +1 -9
- package/src/core/status.ts +1 -21
- package/src/core/targets.ts +52 -70
- package/src/core/types.ts +1 -17
- package/src/core/value-sanitize.ts +38 -1
- package/src/messaging/inbound/commands.ts +2 -24
- package/src/messaging/inbound/dispatch-prep.ts +1 -1
- package/src/messaging/inbound/gate.ts +2 -31
- package/src/messaging/inbound/group-history.ts +0 -75
- package/src/messaging/inbound/native-command.ts +7 -3
- package/src/messaging/inbound/parse.ts +1 -31
- package/src/messaging/inbound/scene-admin.ts +181 -5
- package/src/messaging/outbound/diagnostics.ts +1 -6
- package/src/messaging/outbound/durable-queue-adapter.ts +17 -11
- package/src/messaging/outbound/marker-parser.ts +188 -0
- package/src/messaging/outbound/media-dedupe.ts +2 -6
- package/src/messaging/outbound/media.ts +6 -20
- package/src/messaging/outbound/normalize-outbound-send.ts +327 -0
- package/src/messaging/outbound/queue-selectors.ts +1 -5
- package/src/messaging/outbound/reply-enqueue-media.ts +4 -0
- package/src/messaging/outbound/reply-enqueue.ts +130 -57
- package/src/messaging/outbound/session-route.ts +6 -11
- package/src/messaging/outbound/target-resolver.ts +1 -6
- package/src/openclaw/routing-runtime.ts +0 -11
- package/src/openclaw/sdk-helpers.ts +0 -2
- package/src/plugin/bridge-file-transfer-push-facade.ts +8 -6
- package/src/plugin/bridge-media-facade.ts +1 -1
- package/src/plugin/channel-components.ts +0 -13
- package/src/plugin/channel-plugin-surface-group.ts +57 -40
- package/src/plugin/channel-runtime-builders-delivery.ts +3 -0
- package/src/plugin/channel-runtime-types.ts +10 -24
- package/src/plugin/channel-send.ts +241 -154
- package/src/plugin/channel-utils.ts +8 -29
- package/src/plugin/connection-handlers-helpers.ts +0 -15
- package/src/plugin/connection-handlers.ts +6 -5
- package/src/plugin/file-inbound-abort.ts +5 -0
- package/src/plugin/file-inbound-chunk.ts +5 -0
- package/src/plugin/file-inbound-complete.ts +5 -0
- package/src/plugin/file-transfer-orchestrator.ts +105 -3
- package/src/plugin/file-transfer-setup.ts +1 -1
- package/src/plugin/media-dedupe-runtime.ts +1 -6
- package/src/plugin/media-orchestrators-runtime-group.ts +2 -0
- package/src/plugin/message-send.ts +7 -8
- package/src/plugin/outbound.ts +6 -22
- package/src/plugin/outbox-drain-loop.ts +2 -2
- package/src/plugin/outbox-file-push-flow.ts +7 -7
- package/src/plugin/outbox-text-push-flow.ts +1 -1
- package/src/plugin/target-runtime.ts +1 -6
- package/src/runtime/outbound-ack-timeout.ts +1 -8
- package/src/runtime/outbox-transitions.ts +1 -4
- package/src/runtime/status-worker.ts +1 -9
- package/src/messaging/outbound/build-send-action.ts +0 -130
- package/src/messaging/outbound/durable-message-adapter.ts +0 -121
- package/src/messaging/outbound/send-params.ts +0 -81
- package/src/messaging/outbound/send.ts +0 -121
- package/src/openclaw/ingress-runtime.ts +0 -25
- 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
|
-
|
|
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
|
-
|
|
121
|
+
function withoutReplyMediaText(payload: NormalizedReplyPayload): NormalizedReplyPayload {
|
|
115
122
|
return {
|
|
116
123
|
...payload,
|
|
117
124
|
text: '',
|
|
118
125
|
};
|
|
119
126
|
}
|
|
120
127
|
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
243
|
+
const plan = buildReplyEnqueuePlan(sendPayload);
|
|
218
244
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
237
|
-
helpers.enqueueReplyMediaEntries({
|
|
273
|
+
helpers.enqueueReplyTextEntry({
|
|
238
274
|
accountId: params.accountId,
|
|
239
275
|
sessionKey: params.sessionKey,
|
|
240
276
|
route: params.route,
|
|
241
|
-
payload:
|
|
242
|
-
mediaLocalRoots: params.mediaLocalRoots,
|
|
277
|
+
payload: sendPayload,
|
|
243
278
|
});
|
|
244
|
-
return;
|
|
245
279
|
}
|
|
280
|
+
}
|
|
246
281
|
|
|
247
|
-
|
|
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
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
274
|
-
|
|
323
|
+
downloadMedia: payload?.downloadMedia,
|
|
324
|
+
type: helpers.asString(payload?.type || '').trim() || undefined,
|
|
275
325
|
kind: payload?.kind,
|
|
276
|
-
|
|
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
|
|
279
|
-
replyToId: payload?.replyToId,
|
|
280
|
-
replyTargetPolicy
|
|
351
|
+
kind,
|
|
352
|
+
replyToId: normalized.replyToId ?? payload?.replyToId,
|
|
353
|
+
replyTargetPolicy,
|
|
281
354
|
}),
|
|
282
355
|
};
|
|
283
356
|
}
|