@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
@@ -3,13 +3,38 @@ import { normalizeAccountId } from '../core/accounts.ts';
3
3
  import { buildBncrDebugJsonMessage } from '../core/logging.ts';
4
4
  import type { BncrRoute, OutboxEntry } from '../core/types.ts';
5
5
  import { buildBncrDurableQueuedResult } from '../messaging/outbound/durable-queue-adapter.ts';
6
- import { extractConsumptionFields, parseBncrMarker } from '../messaging/outbound/marker-parser.ts';
6
+
7
7
  import type { ReplyPayloadInput } from '../messaging/outbound/reply-enqueue.ts';
8
- import { sendBncrMedia, sendBncrText } from '../messaging/outbound/send.ts';
9
8
  import type { BncrChannelSendContext, BncrVerifiedTarget } from './channel-runtime-types.ts';
10
9
 
11
- function normalizeReplyKind(value: unknown): ReplyPayloadInput['kind'] {
12
- return value === 'tool' || value === 'block' || value === 'final' ? value : undefined;
10
+ /**
11
+ * Resolve downloadMedia with cascade: host override > ctx > scene > default(false).
12
+ * Marker-level downloadMedia is extracted downstream by the orchestrator's
13
+ * normalisation and has highest priority (overrides whatever we pass).
14
+ */
15
+
16
+ /** Override fields shared by sendDispatch and messageSendDispatch. */
17
+ type ChannelSendOverrideFields = {
18
+ text?: string;
19
+ mediaUrl?: string;
20
+ mediaUrls?: unknown;
21
+ asVoice?: boolean;
22
+ audioAsVoice?: boolean;
23
+ downloadMedia?: boolean;
24
+ type?: string;
25
+ kind?: string;
26
+ replyToId?: string;
27
+ };
28
+
29
+ function resolveDownloadMedia(
30
+ to: string,
31
+ overrideValue: boolean | undefined,
32
+ ctxValue: boolean | undefined,
33
+ runtime: BncrChannelSendRuntime,
34
+ ): boolean | undefined {
35
+ if (overrideValue !== undefined) return overrideValue;
36
+ if (ctxValue !== undefined) return ctxValue;
37
+ return runtime.resolveSceneDownloadMedia?.(to);
13
38
  }
14
39
 
15
40
  export type BncrChannelSendRuntime = {
@@ -27,106 +52,15 @@ export type BncrChannelSendRuntime = {
27
52
  mediaLocalRoots?: readonly string[];
28
53
  }) => Promise<void>;
29
54
  listOutboxEntries: () => OutboxEntry[];
55
+ /** Resolve downloadMedia from scene config (host/ctx already resolved above). */
56
+ resolveSceneDownloadMedia?: (to: string) => boolean | undefined;
30
57
  };
31
58
 
32
- /**
33
- * Merge all extra sources (ctx.extra, marker params, host-level fields)
34
- * into a single extra bag. Strips consumption fields and returns them
35
- * as independent params, remaining extra passes through the pipeline.
36
- */
37
- function resolveUnifiedOutboundExtra(ctx: BncrChannelSendContext): {
38
- cleanText: string;
39
- extra: Record<string, unknown>;
40
- consumed: {
41
- asVoice?: boolean;
42
- audioAsVoice?: boolean;
43
- downloadMedia?: boolean;
44
- type?: string;
45
- kind?: string;
46
- replyToId?: string;
47
- };
48
- } {
49
- // 1. Parse marker from text
50
- const { cleanText, params: markerParams } = parseBncrMarker(
51
- typeof ctx.text === 'string' ? ctx.text : '',
52
- );
53
-
54
- // 2. Merge all sources: extra from ctx + marker params + host-level fields
55
- const merged: Record<string, unknown> = {
56
- ...(typeof ctx.extra === 'object' && ctx.extra !== null ? ctx.extra : {}),
57
- ...markerParams,
58
- };
59
- if (ctx.forceDocument === true) merged.forceDocument = true;
60
- if (ctx.gifPlayback === true) merged.gifPlayback = true;
61
- if (ctx.silent === true) merged.silent = true;
62
-
63
- // 3. Strip consumption fields
64
- const { consumed, remaining } = extractConsumptionFields(merged);
65
-
66
- return {
67
- cleanText,
68
- extra: remaining,
69
- consumed,
70
- };
71
- }
72
-
73
- function resolveChannelSendReplyToId(
74
- asString: BncrChannelSendRuntime['asString'],
75
- ctx: BncrChannelSendContext,
76
- ) {
77
- return asString(ctx?.replyToId || ctx?.replyToMessageId || '').trim() || undefined;
78
- }
79
-
80
- function logChannelSendEntry(
81
- runtime: BncrChannelSendRuntime,
82
- args: {
83
- kind: 'text' | 'media';
84
- accountId: string;
85
- to: string;
86
- ctx: BncrChannelSendContext;
87
- payload: {
88
- text: string;
89
- mediaUrl: string;
90
- mediaUrls?: string[];
91
- asVoice?: boolean;
92
- audioAsVoice?: boolean;
93
- downloadMedia?: boolean;
94
- };
95
- },
96
- ) {
97
- runtime.logInfo(
98
- 'outbound',
99
- buildBncrDebugJsonMessage(`send-entry:${args.kind}`, {
100
- accountId: args.accountId,
101
- to: args.to,
102
- text: args.payload.text,
103
- mediaUrl: args.payload.mediaUrl,
104
- mediaUrls: args.payload.mediaUrls,
105
- asVoice: args.payload.asVoice,
106
- audioAsVoice: args.payload.audioAsVoice,
107
- downloadMedia: args.payload.downloadMedia,
108
- sessionKey: runtime.asString(args.ctx?.sessionKey || ''),
109
- mirrorSessionKey: runtime.asString(args.ctx?.mirror?.sessionKey || ''),
110
- rawCtx: {
111
- to: args.ctx?.to,
112
- accountId: args.ctx?.accountId,
113
- threadId: args.ctx?.threadId,
114
- replyToId: args.ctx?.replyToId,
115
- },
116
- }),
117
- { debugOnly: true },
118
- );
119
- }
120
-
121
59
  async function enqueueChannelMessageHandoff(
122
60
  runtime: BncrChannelSendRuntime,
123
61
  ctx: BncrChannelSendContext,
124
62
  payload: ReplyPayloadInput,
125
- extra?: Record<string, unknown>,
126
63
  ) {
127
- if (extra && Object.keys(extra).length > 0) {
128
- payload = { ...payload, extra: { ...extra } };
129
- }
130
64
  const accountId = normalizeAccountId(ctx.accountId);
131
65
  const to = runtime.asString(ctx.to || '').trim();
132
66
  const verified = runtime.resolveVerifiedTarget(to, accountId);
@@ -146,180 +80,231 @@ async function enqueueChannelMessageHandoff(
146
80
  return entries[entries.length - 1];
147
81
  }
148
82
 
83
+ /**
84
+ * Resolve downloadMedia from scene config when not already set.
85
+ * Scene cascade: host override > ctx > scene config > default(false).
86
+ * (Marker-level downloadMedia overrides everything and is handled downstream.)
87
+ */
88
+
149
89
  export function createBncrChannelSendRuntime(runtime: BncrChannelSendRuntime) {
150
- /**
151
- * Single dispatch: resolves unified extra (marker + ctx + host fields),
152
- * determines text vs media route, and calls the appropriate send function.
153
- */
154
- async function sendDispatch(ctx: BncrChannelSendContext) {
155
- await runtime.syncDebugFlag();
156
- const accountId = normalizeAccountId(ctx.accountId);
90
+ /** Extract media-related override fields from a channel send context. */
91
+ function mediaOverrides(ctx: BncrChannelSendContext) {
92
+ return {
93
+ mediaUrl: runtime.asString(ctx.mediaUrl || ''),
94
+ mediaUrls: ctx.mediaUrls,
95
+ asVoice: ctx.asVoice === true,
96
+ audioAsVoice: ctx.audioAsVoice === true,
97
+ type: runtime.asString(ctx.type || '').trim() || undefined,
98
+ };
99
+ }
100
+
101
+ function extractSendFields(
102
+ ctx: BncrChannelSendContext,
103
+ overrides?: ChannelSendOverrideFields,
104
+ ): {
105
+ accountId: string;
106
+ to: string;
107
+ text: string;
108
+ mediaUrl: string;
109
+ mediaUrls: unknown;
110
+ asVoice: boolean;
111
+ audioAsVoice: boolean;
112
+ type: string | undefined;
113
+ kind: unknown;
114
+ replyToId: string | undefined;
115
+ extra: Record<string, unknown> | undefined;
116
+ downloadMedia: boolean | undefined;
117
+ } {
157
118
  const to = runtime.asString(ctx.to || '').trim();
158
- const { cleanText, extra, consumed } = resolveUnifiedOutboundExtra(ctx);
159
- const replyToId = resolveChannelSendReplyToId(runtime.asString, ctx);
160
119
 
161
- // Resolve effective media params: ctx.mediaUrl is the source of truth.
162
- // extra.path/mediaUrl from marker only triggers media when:
163
- // - it is a local file path (starts with / or ./), OR
164
- // - consumed.type is a known media type (file/image/video/audio)
165
- // Otherwise they are treated as metadata (e.g. appmsg thumbnail URL).
166
- const ctxMediaUrl = runtime.asString(ctx.mediaUrl || '').trim();
167
- const markerPath = runtime
168
- .asString((extra.path as string) || (extra.mediaUrl as string) || '')
169
- .trim();
170
- const isLocalPath =
171
- markerPath.startsWith('/') || markerPath.startsWith('./') || markerPath.startsWith('../');
172
- const isMediaType = ['file', 'image', 'video', 'audio'].includes(consumed.type || '');
173
- const effectiveMediaUrl = ctxMediaUrl || (isLocalPath || isMediaType ? markerPath : '');
174
- const effectiveMediaUrls = Array.isArray(ctx?.mediaUrls) ? ctx.mediaUrls : undefined;
175
- const asVoice = consumed.asVoice ?? ctx?.asVoice === true;
176
- const audioAsVoice = consumed.audioAsVoice ?? ctx?.audioAsVoice === true;
177
- const downloadMedia = consumed.downloadMedia ?? ctx?.downloadMedia ?? false;
178
- const type = consumed.type ?? (runtime.asString(ctx?.type || '').trim() || undefined);
179
- const hasMedia = !!(effectiveMediaUrl || effectiveMediaUrls?.length);
180
- console.log(
181
- '[bncr] send-dispatch cleanText=' +
182
- JSON.stringify(cleanText) +
183
- '|downloadMedia=' +
184
- JSON.stringify(consumed.downloadMedia) +
185
- '|hasMedia=' +
186
- hasMedia +
187
- '|mediaUrl=' +
188
- JSON.stringify(effectiveMediaUrl),
120
+ // Extract fields from context / overrides WITHOUT normalising markers.
121
+ // Raw text (with markers) is passed downstream; the orchestrator handles
122
+ // all normalisation including marker parsing, pre-send, and media routing.
123
+ const text = overrides?.text !== undefined ? overrides.text : runtime.asString(ctx.text || '');
124
+ const mediaUrl =
125
+ overrides?.mediaUrl !== undefined ? overrides.mediaUrl : runtime.asString(ctx.mediaUrl || '');
126
+ const mediaUrls = overrides?.mediaUrls !== undefined ? overrides.mediaUrls : ctx.mediaUrls;
127
+ const asVoice = overrides?.asVoice ?? ctx.asVoice === true;
128
+ const audioAsVoice = overrides?.audioAsVoice ?? ctx.audioAsVoice === true;
129
+ const type =
130
+ overrides?.type !== undefined
131
+ ? overrides.type
132
+ : runtime.asString(ctx.type || '').trim() || undefined;
133
+ const kind = overrides?.kind ?? ctx.kind;
134
+ const replyToId =
135
+ (overrides?.replyToId ??
136
+ runtime.asString(ctx.replyToId || ctx.replyToMessageId || '').trim()) ||
137
+ undefined;
138
+ const extra =
139
+ typeof ctx.extra === 'object' && ctx.extra !== null ? { ...ctx.extra } : undefined;
140
+ const downloadMedia = resolveDownloadMedia(
141
+ to,
142
+ overrides?.downloadMedia,
143
+ ctx.downloadMedia,
144
+ runtime,
189
145
  );
190
146
 
191
- const kind: 'text' | 'media' = hasMedia ? 'media' : 'text';
192
- logChannelSendEntry(runtime, {
147
+ return {
148
+ accountId: normalizeAccountId(ctx.accountId),
149
+ to,
150
+ text: text,
151
+ mediaUrl: mediaUrl,
152
+ mediaUrls,
153
+ asVoice,
154
+ audioAsVoice,
155
+ type,
193
156
  kind,
157
+ replyToId,
158
+ extra,
159
+ downloadMedia,
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Single dispatch path for channelSendText / channelSendMedia.
165
+ * Both normalize first then branch media vs text via hasMedia.
166
+ * channelSendMedia passes mediaOverrides(ctx) to ensure media routing,
167
+ * channelSendText passes no overrides and relies on ctx content alone.
168
+ */
169
+ async function sendDispatch(
170
+ ctx: BncrChannelSendContext,
171
+ overrides?: {
172
+ text?: string;
173
+ mediaUrl?: string;
174
+ mediaUrls?: unknown;
175
+ asVoice?: boolean;
176
+ audioAsVoice?: boolean;
177
+ downloadMedia?: boolean;
178
+ type?: string;
179
+ kind?: string;
180
+ replyToId?: string;
181
+ },
182
+ ) {
183
+ await runtime.syncDebugFlag();
184
+ const {
194
185
  accountId,
195
186
  to,
196
- ctx,
197
- payload: {
198
- text: cleanText,
199
- mediaUrl: effectiveMediaUrl,
200
- mediaUrls: effectiveMediaUrls,
201
- asVoice,
202
- audioAsVoice,
203
- },
204
- });
187
+ text,
188
+ mediaUrl,
189
+ mediaUrls,
190
+ asVoice,
191
+ audioAsVoice,
192
+ type,
193
+ kind,
194
+ replyToId,
195
+ extra,
196
+ downloadMedia,
197
+ } = extractSendFields(ctx, overrides);
205
198
 
206
- if (hasMedia) {
207
- return sendBncrMedia({
208
- channelId: runtime.channelId,
199
+ runtime.logInfo(
200
+ 'outbound',
201
+ buildBncrDebugJsonMessage('send-raw', {
209
202
  accountId,
210
203
  to,
211
- text: cleanText,
212
- mediaUrl: effectiveMediaUrl,
213
- mediaUrls: effectiveMediaUrls,
214
- asVoice,
215
- audioAsVoice,
204
+ textLen: text.length,
205
+ hasMedia: Boolean(mediaUrl || (Array.isArray(mediaUrls) && mediaUrls.length)),
216
206
  downloadMedia,
217
- type,
218
- extra: Object.keys(extra).length > 0 ? extra : undefined,
219
- kind: normalizeReplyKind(ctx?.kind),
220
- replyToId,
221
- mediaLocalRoots: ctx.mediaLocalRoots,
222
- resolveVerifiedTarget: (...a) => runtime.resolveVerifiedTarget(...a),
223
- rememberSessionRoute: (...a) => runtime.rememberSessionRoute(...a),
224
- enqueueFromReply: (a) => runtime.enqueueFromReply(a),
225
- createMessageId: () => randomUUID(),
226
- });
227
- }
207
+ }),
208
+ { debugOnly: true },
209
+ );
210
+
211
+ const verified = runtime.resolveVerifiedTarget(to, accountId);
212
+ runtime.rememberSessionRoute(verified.sessionKey, accountId, verified.route);
228
213
 
229
- return sendBncrText({
230
- channelId: runtime.channelId,
214
+ await runtime.enqueueFromReply({
231
215
  accountId,
232
- to,
233
- text: cleanText,
234
- kind: normalizeReplyKind(ctx?.kind),
235
- replyToId,
236
- extra: Object.keys(extra).length > 0 ? extra : undefined,
216
+ sessionKey: verified.sessionKey,
217
+ route: verified.route,
218
+ payload: {
219
+ text: text,
220
+ mediaUrl: mediaUrl || undefined,
221
+ mediaUrls: Array.isArray(mediaUrls) && mediaUrls.length ? mediaUrls : undefined,
222
+ asVoice: asVoice || undefined,
223
+ audioAsVoice: audioAsVoice || undefined,
224
+ downloadMedia,
225
+ type,
226
+ extra,
227
+ kind: kind as 'tool' | 'block' | 'final' | undefined,
228
+ replyToId,
229
+ },
237
230
  mediaLocalRoots: ctx.mediaLocalRoots,
238
- resolveVerifiedTarget: (...a) => runtime.resolveVerifiedTarget(...a),
239
- rememberSessionRoute: (...a) => runtime.rememberSessionRoute(...a),
240
- enqueueFromReply: (a) => runtime.enqueueFromReply(a),
241
- createMessageId: () => randomUUID(),
242
231
  });
232
+
233
+ return {
234
+ channel: runtime.channelId,
235
+ messageId: randomUUID(),
236
+ chatId: verified.sessionKey,
237
+ };
243
238
  }
244
239
 
245
240
  /**
246
- * Durable dispatch: same logic but enqueues via channel message handoff.
241
+ * Durable channel.message path: same normalize, then enqueue handoff.
247
242
  */
248
243
  async function messageSendDispatch(
249
244
  ctx: BncrChannelSendContext,
250
- inputOverrides?: {
245
+ overrides?: {
251
246
  text?: string;
252
247
  mediaUrl?: string;
253
- mediaUrls?: string[];
248
+ mediaUrls?: unknown;
254
249
  asVoice?: boolean;
255
250
  audioAsVoice?: boolean;
256
251
  downloadMedia?: boolean;
257
252
  type?: string;
258
- kind?: ReplyPayloadInput['kind'];
253
+ kind?: string;
259
254
  replyToId?: string;
260
255
  },
261
256
  ) {
262
- // Merge inputOverrides text into ctx so marker parsing sees the effective text
263
- const effectiveCtx =
264
- inputOverrides?.text !== undefined ? { ...ctx, text: inputOverrides.text } : ctx;
265
- const { cleanText, extra, consumed } = resolveUnifiedOutboundExtra(effectiveCtx);
266
- const replyToId = resolveChannelSendReplyToId(runtime.asString, ctx);
267
-
268
- console.log(
269
- '[bncr] msg-dispatch cleanText=' +
270
- JSON.stringify(cleanText) +
271
- '|downloadMedia=' +
272
- JSON.stringify(consumed.downloadMedia) +
273
- '|mediaUrl=' +
274
- JSON.stringify(inputOverrides?.mediaUrl),
275
- );
257
+ const {
258
+ text,
259
+ mediaUrl,
260
+ mediaUrls,
261
+ asVoice,
262
+ audioAsVoice,
263
+ type,
264
+ kind,
265
+ replyToId,
266
+ extra,
267
+ downloadMedia,
268
+ } = extractSendFields(ctx, overrides);
276
269
 
277
- const payload: ReplyPayloadInput = {
278
- text: cleanText,
279
- mediaUrl: inputOverrides?.mediaUrl || '',
280
- mediaUrls: inputOverrides?.mediaUrls,
281
- asVoice: consumed.asVoice ?? inputOverrides?.asVoice ?? false,
282
- audioAsVoice: consumed.audioAsVoice ?? inputOverrides?.audioAsVoice ?? false,
283
- downloadMedia: consumed.downloadMedia ?? inputOverrides?.downloadMedia ?? false,
284
- type: consumed.type ?? inputOverrides?.type,
285
- kind: normalizeReplyKind(consumed.kind) ?? inputOverrides?.kind,
286
- replyToId: inputOverrides?.replyToId || replyToId,
287
- ...(Object.keys(extra).length > 0 ? { extra: { ...extra } } : {}),
288
- };
289
- const entry = await enqueueChannelMessageHandoff(runtime, ctx, payload, extra);
270
+ const entry = await enqueueChannelMessageHandoff(runtime, ctx, {
271
+ text: text,
272
+ mediaUrl: mediaUrl || '',
273
+ mediaUrls: Array.isArray(mediaUrls) && mediaUrls.length ? mediaUrls : undefined,
274
+ asVoice,
275
+ audioAsVoice,
276
+ downloadMedia,
277
+ type,
278
+ extra,
279
+ kind: kind as 'tool' | 'block' | 'final' | undefined,
280
+ replyToId,
281
+ });
290
282
  return buildBncrDurableQueuedResult({ entry });
291
283
  }
292
284
 
293
285
  return {
294
- channelSendText: sendDispatch,
295
- channelSendMedia: sendDispatch,
286
+ channelSendText: (ctx: BncrChannelSendContext) => sendDispatch(ctx),
287
+ channelSendMedia: (ctx: BncrChannelSendContext) => sendDispatch(ctx, mediaOverrides(ctx)),
296
288
 
297
- channelMessageSendText: async (ctx: BncrChannelSendContext) =>
298
- messageSendDispatch(ctx, { kind: normalizeReplyKind(ctx?.kind) }),
289
+ channelMessageSendText: async (ctx: BncrChannelSendContext) => messageSendDispatch(ctx),
299
290
 
300
291
  channelMessageSendMedia: async (ctx: BncrChannelSendContext) =>
301
- messageSendDispatch(ctx, {
302
- mediaUrl: runtime.asString(ctx.mediaUrl || ''),
303
- mediaUrls: Array.isArray(ctx?.mediaUrls) ? ctx.mediaUrls : undefined,
304
- asVoice: ctx?.asVoice === true,
305
- audioAsVoice: ctx?.audioAsVoice === true,
306
- type: runtime.asString(ctx.type || '').trim() || undefined,
307
- kind: normalizeReplyKind(ctx?.kind),
308
- }),
292
+ messageSendDispatch(ctx, mediaOverrides(ctx)),
309
293
 
310
294
  channelMessageSendPayload: async (ctx: BncrChannelSendContext) => {
311
295
  const p = ctx?.payload || {};
312
- if (!p || typeof p !== 'object')
296
+ if (!p || typeof p !== 'object') {
313
297
  throw new Error('bncr channel.message payload must be an object');
298
+ }
314
299
  return messageSendDispatch(ctx, {
315
300
  text: runtime.asString(p.text || p.message || p.caption || ''),
316
301
  mediaUrl: runtime.asString(p.mediaUrl || ''),
317
302
  mediaUrls: Array.isArray(p.mediaUrls) ? p.mediaUrls : undefined,
318
303
  asVoice: p.asVoice === true,
319
304
  audioAsVoice: p.audioAsVoice === true,
320
- downloadMedia: p.downloadMedia === true,
305
+ downloadMedia: p.downloadMedia as boolean | undefined,
321
306
  type: runtime.asString(p.type || '').trim() || undefined,
322
- kind: normalizeReplyKind(p.kind) ?? normalizeReplyKind(ctx?.kind),
307
+ kind: typeof p.kind === 'string' ? p.kind : ctx.kind,
323
308
  replyToId:
324
309
  runtime.asString(p.replyToId || ctx?.replyToId || ctx?.replyToMessageId || '').trim() ||
325
310
  undefined,
@@ -1,40 +1,19 @@
1
1
  import path from 'node:path';
2
+ import {
3
+ asString,
4
+ clampFiniteNumber,
5
+ finiteNumberOr,
6
+ isPlainObject,
7
+ now,
8
+ } from '../core/value-sanitize.ts';
2
9
 
3
- export function now() {
4
- return Date.now();
5
- }
6
-
7
- export function asString(v: unknown, fallback = ''): string {
8
- if (typeof v === 'string') return v;
9
- if (v == null) return fallback;
10
- return String(v);
11
- }
12
-
13
- export function finiteNumberOr(value: unknown, fallback: number): number {
14
- const n = Number(value);
15
- return Number.isFinite(n) ? n : fallback;
16
- }
10
+ export { asString, clampFiniteNumber, finiteNumberOr, isPlainObject, now };
17
11
 
18
12
  export function finiteNonNegativeNumberOrNull(value: unknown): number | null {
19
13
  const n = Number(value);
20
14
  return Number.isFinite(n) && n >= 0 ? n : null;
21
15
  }
22
16
 
23
- export function clampFiniteNumber(
24
- value: unknown,
25
- fallback: number,
26
- min: number,
27
- max: number,
28
- ): number {
29
- const n = Number(value);
30
- const finite = Number.isFinite(n) ? n : fallback;
31
- return Math.max(min, Math.min(finite, max));
32
- }
33
-
34
- export function isPlainObject(value: unknown): value is Record<string, unknown> {
35
- return typeof value === 'object' && value !== null && !Array.isArray(value);
36
- }
37
-
38
17
  export function backoffMs(retryCount: number): number {
39
18
  return Math.max(1_000, 1_000 * 2 ** Math.max(0, retryCount - 1));
40
19
  }
@@ -203,21 +203,6 @@ export function hasTerminalFileAckState(state: FileAckState | undefined): state
203
203
  return state?.status === 'completed' || state?.status === 'aborted';
204
204
  }
205
205
 
206
- export function matchesTransferOwner(args: {
207
- transfer: FileAckState | undefined;
208
- connId: string;
209
- clientId?: string;
210
- }) {
211
- const { transfer, connId, clientId } = args;
212
- const sameConn = !!transfer?.ownerConnId && transfer.ownerConnId === connId;
213
- const sameClient =
214
- !transfer?.ownerConnId &&
215
- !!transfer?.ownerClientId &&
216
- !!clientId &&
217
- transfer.ownerClientId === clientId;
218
- return { sameConn, sameClient };
219
- }
220
-
221
206
  export function applyFileAckState(args: {
222
207
  transfer: FileSendTransferState;
223
208
  stage: BncrFileAckStage;
@@ -1,5 +1,6 @@
1
1
  import type { GatewayRequestHandlerOptions } from 'openclaw/plugin-sdk/core';
2
2
  import { CHANNEL_ID } from '../core/accounts.ts';
3
+ import { matchesTransferOwner } from '../core/lease-state.ts';
3
4
  import type { FileSendTransferState } from '../core/types.ts';
4
5
  import {
5
6
  applyFileAckState,
@@ -16,7 +17,6 @@ import {
16
17
  hasTerminalFileAckState,
17
18
  isBncrFileAckStage,
18
19
  type LeaseEventPayload,
19
- matchesTransferOwner,
20
20
  type PreparedAckHandling,
21
21
  resolveFileAckLeaseEventKind,
22
22
  } from './connection-handlers-helpers.ts';
@@ -363,20 +363,21 @@ export function createBncrConnectionHandlers(runtime: BncrConnectionHandlersRunt
363
363
  let resolvedState = activeTransfer?.status ?? 'late';
364
364
 
365
365
  if (staleObserved.stale) {
366
- const { sameConn, sameClient } = matchesTransferOwner({
367
- transfer: activeTransfer,
366
+ const sameOwner = matchesTransferOwner({
367
+ ownerConnId: transferOwnerConnId,
368
+ ownerClientId: transferOwnerClientId,
368
369
  connId,
369
370
  clientId,
370
371
  });
371
372
  const adopted =
372
- !(sameConn || sameClient) &&
373
+ !sameOwner &&
373
374
  runtime.tryAdoptTransferOwner({
374
375
  accountId,
375
376
  transfer: activeTransfer,
376
377
  connId,
377
378
  clientId,
378
379
  });
379
- if (!(sameConn || sameClient || adopted)) {
380
+ if (!sameOwner && !adopted) {
380
381
  runtime.logWarn(
381
382
  'stale',
382
383
  `ignore kind=file.ack accountId=${accountId} connId=${connId} clientId=${clientId || '-'} transferId=${transferId} stage=${stage} reason=owner-mismatch ownerConnId=${transferOwnerConnId || '-'} ownerClientId=${transferOwnerClientId || '-'}`,
@@ -59,6 +59,11 @@ export function createBncrFileInboundAbortHandler(runtime: BncrFileInboundRuntim
59
59
  }
60
60
 
61
61
  const staleObserved = runtime.observeLease('file.abort', params ?? {});
62
+ // #not-merged Intentionally duplicated across complete/chunk/abort.
63
+ // Each handler emits a distinct logWarn message with its own event-kind
64
+ // in the format string, and the surrounding logic diverges in more than
65
+ // just the event name. Merging would add parameter-gluing boilerplate
66
+ // with no reduction in total LOC. See also the sibling files.
62
67
  if (staleObserved.stale) {
63
68
  if (
64
69
  !runtime.matchesTransferOwner({
@@ -78,6 +78,11 @@ export function createBncrFileInboundChunkHandler(runtime: BncrFileInboundRuntim
78
78
  }
79
79
 
80
80
  const staleObserved = runtime.observeLease('file.chunk', params ?? {});
81
+ // #not-merged Intentionally duplicated across complete/chunk/abort.
82
+ // Each handler emits a distinct logWarn message with its own event-kind
83
+ // in the format string, and the surrounding logic diverges in more than
84
+ // just the event name. Merging would add parameter-gluing boilerplate
85
+ // with no reduction in total LOC. See also the sibling files.
81
86
  if (staleObserved.stale) {
82
87
  if (
83
88
  !runtime.matchesTransferOwner({
@@ -63,6 +63,11 @@ export function createBncrFileInboundCompleteHandler(runtime: BncrFileInboundRun
63
63
  }
64
64
 
65
65
  const staleObserved = runtime.observeLease('file.complete', params ?? {});
66
+ // #not-merged Intentionally duplicated across complete/chunk/abort.
67
+ // Each handler emits a distinct logWarn message with its own event-kind
68
+ // in the format string, and the surrounding logic diverges in more than
69
+ // just the event name. Merging would add parameter-gluing boilerplate
70
+ // with no reduction in total LOC. See also the sibling files.
66
71
  if (staleObserved.stale) {
67
72
  if (
68
73
  !runtime.matchesTransferOwner({