@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.
- 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 +79 -43
- 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 +34 -18
- 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/parse.ts +1 -31
- package/src/messaging/inbound/scene-admin.ts +8 -8
- 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 +62 -28
- package/src/messaging/outbound/media-dedupe.ts +2 -6
- package/src/messaging/outbound/media.ts +1 -6
- 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.ts +121 -70
- 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 +6 -6
- package/src/plugin/bridge-media-facade.ts +0 -1
- package/src/plugin/channel-components.ts +0 -13
- package/src/plugin/channel-plugin-surface-group.ts +52 -65
- package/src/plugin/channel-runtime-builders-delivery.ts +1 -0
- package/src/plugin/channel-runtime-types.ts +2 -24
- package/src/plugin/channel-send.ts +205 -221
- 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/media-dedupe-runtime.ts +1 -6
- package/src/plugin/message-send.ts +7 -8
- package/src/plugin/outbound.ts +6 -26
- 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 -114
- package/src/messaging/outbound/send.ts +0 -127
- 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
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { BncrRoute, OutboxEntry } from '../../core/types.ts';
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
121
|
+
function withoutReplyMediaText(payload: NormalizedReplyPayload): NormalizedReplyPayload {
|
|
119
122
|
return {
|
|
120
123
|
...payload,
|
|
121
124
|
text: '',
|
|
122
125
|
};
|
|
123
126
|
}
|
|
124
127
|
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
243
|
+
const plan = buildReplyEnqueuePlan(sendPayload);
|
|
227
244
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
246
|
-
helpers.enqueueReplyMediaEntries({
|
|
273
|
+
helpers.enqueueReplyTextEntry({
|
|
247
274
|
accountId: params.accountId,
|
|
248
275
|
sessionKey: params.sessionKey,
|
|
249
276
|
route: params.route,
|
|
250
|
-
payload:
|
|
251
|
-
mediaLocalRoots: params.mediaLocalRoots,
|
|
277
|
+
payload: sendPayload,
|
|
252
278
|
});
|
|
253
|
-
return;
|
|
254
279
|
}
|
|
280
|
+
}
|
|
255
281
|
|
|
256
|
-
|
|
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
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
295
|
-
|
|
296
|
-
...(finalExtra ? { extra: finalExtra } : {}),
|
|
323
|
+
downloadMedia: payload?.downloadMedia,
|
|
324
|
+
type: helpers.asString(payload?.type || '').trim() || undefined,
|
|
297
325
|
kind: payload?.kind,
|
|
298
|
-
|
|
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
|
|
301
|
-
replyToId: payload?.replyToId,
|
|
302
|
-
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
|
|
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
|
|
97
|
+
...(params.threadId != null ? { threadId: params.threadId } : {}),
|
|
103
98
|
});
|
|
104
99
|
|
|
105
100
|
return attachBncrChannelRouteRefFields({
|