@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.
Files changed (73) 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 -6
  5. package/scripts/selfcheck.mjs +0 -7
  6. package/src/channel.ts +79 -43
  7. package/src/core/accounts.ts +1 -6
  8. package/src/core/connection-reachability.ts +1 -5
  9. package/src/core/dead-letter-diagnostics.ts +4 -10
  10. package/src/core/outbox-entry-builders.ts +34 -18
  11. package/src/core/outbox-file-transfer-failure.ts +5 -0
  12. package/src/core/outbox-file-transfer-guards.ts +6 -0
  13. package/src/core/outbox-file-transfer-prep.ts +8 -5
  14. package/src/core/outbox-file-transfer-success.ts +5 -0
  15. package/src/core/outbox-queue.ts +1 -7
  16. package/src/core/outbox-summary.ts +15 -27
  17. package/src/core/outbox-text-push-prep.ts +1 -36
  18. package/src/core/permissions.ts +1 -9
  19. package/src/core/persisted-outbox-entry.ts +54 -11
  20. package/src/core/policy.ts +1 -11
  21. package/src/core/register-trace.ts +2 -5
  22. package/src/core/status-meta.ts +1 -9
  23. package/src/core/status.ts +1 -21
  24. package/src/core/targets.ts +52 -70
  25. package/src/core/types.ts +1 -17
  26. package/src/core/value-sanitize.ts +38 -1
  27. package/src/messaging/inbound/commands.ts +2 -24
  28. package/src/messaging/inbound/dispatch-prep.ts +1 -1
  29. package/src/messaging/inbound/gate.ts +2 -31
  30. package/src/messaging/inbound/group-history.ts +0 -75
  31. package/src/messaging/inbound/parse.ts +1 -31
  32. package/src/messaging/inbound/scene-admin.ts +8 -8
  33. package/src/messaging/outbound/diagnostics.ts +1 -6
  34. package/src/messaging/outbound/durable-queue-adapter.ts +17 -11
  35. package/src/messaging/outbound/marker-parser.ts +62 -28
  36. package/src/messaging/outbound/media-dedupe.ts +2 -6
  37. package/src/messaging/outbound/media.ts +1 -6
  38. package/src/messaging/outbound/normalize-outbound-send.ts +327 -0
  39. package/src/messaging/outbound/queue-selectors.ts +1 -5
  40. package/src/messaging/outbound/reply-enqueue.ts +121 -70
  41. package/src/messaging/outbound/session-route.ts +6 -11
  42. package/src/messaging/outbound/target-resolver.ts +1 -6
  43. package/src/openclaw/routing-runtime.ts +0 -11
  44. package/src/openclaw/sdk-helpers.ts +0 -2
  45. package/src/plugin/bridge-file-transfer-push-facade.ts +6 -6
  46. package/src/plugin/bridge-media-facade.ts +0 -1
  47. package/src/plugin/channel-components.ts +0 -13
  48. package/src/plugin/channel-plugin-surface-group.ts +52 -65
  49. package/src/plugin/channel-runtime-builders-delivery.ts +1 -0
  50. package/src/plugin/channel-runtime-types.ts +2 -24
  51. package/src/plugin/channel-send.ts +205 -221
  52. package/src/plugin/channel-utils.ts +8 -29
  53. package/src/plugin/connection-handlers-helpers.ts +0 -15
  54. package/src/plugin/connection-handlers.ts +6 -5
  55. package/src/plugin/file-inbound-abort.ts +5 -0
  56. package/src/plugin/file-inbound-chunk.ts +5 -0
  57. package/src/plugin/file-inbound-complete.ts +5 -0
  58. package/src/plugin/media-dedupe-runtime.ts +1 -6
  59. package/src/plugin/message-send.ts +7 -8
  60. package/src/plugin/outbound.ts +6 -26
  61. package/src/plugin/outbox-drain-loop.ts +2 -2
  62. package/src/plugin/outbox-file-push-flow.ts +7 -7
  63. package/src/plugin/outbox-text-push-flow.ts +1 -1
  64. package/src/plugin/target-runtime.ts +1 -6
  65. package/src/runtime/outbound-ack-timeout.ts +1 -8
  66. package/src/runtime/outbox-transitions.ts +1 -4
  67. package/src/runtime/status-worker.ts +1 -9
  68. package/src/messaging/outbound/build-send-action.ts +0 -130
  69. package/src/messaging/outbound/durable-message-adapter.ts +0 -121
  70. package/src/messaging/outbound/send-params.ts +0 -114
  71. package/src/messaging/outbound/send.ts +0 -127
  72. package/src/openclaw/ingress-runtime.ts +0 -25
  73. package/src/plugin/channel-send-runtime-group.ts +0 -37
@@ -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: override (host/user) > ctx > scene.
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: marker > scene > global > default(false). */
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,230 @@ 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 by marker/host.
85
+ * Scene cascade: marker > scene config > global > default(false).
86
+ */
87
+
149
88
  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);
89
+ /** Extract media-related override fields from a channel send context. */
90
+ function mediaOverrides(ctx: BncrChannelSendContext) {
91
+ return {
92
+ mediaUrl: runtime.asString(ctx.mediaUrl || ''),
93
+ mediaUrls: ctx.mediaUrls,
94
+ asVoice: ctx.asVoice === true,
95
+ audioAsVoice: ctx.audioAsVoice === true,
96
+ type: runtime.asString(ctx.type || '').trim() || undefined,
97
+ };
98
+ }
99
+
100
+ function extractSendFields(
101
+ ctx: BncrChannelSendContext,
102
+ overrides?: ChannelSendOverrideFields,
103
+ ): {
104
+ accountId: string;
105
+ to: string;
106
+ text: string;
107
+ mediaUrl: string;
108
+ mediaUrls: unknown;
109
+ asVoice: boolean;
110
+ audioAsVoice: boolean;
111
+ type: string | undefined;
112
+ kind: unknown;
113
+ replyToId: string | undefined;
114
+ extra: Record<string, unknown> | undefined;
115
+ downloadMedia: boolean | undefined;
116
+ } {
157
117
  const to = runtime.asString(ctx.to || '').trim();
158
- const { cleanText, extra, consumed } = resolveUnifiedOutboundExtra(ctx);
159
- const replyToId = resolveChannelSendReplyToId(runtime.asString, ctx);
160
118
 
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),
119
+ // Extract fields from context / overrides WITHOUT normalising markers.
120
+ // Raw text (with markers) is passed downstream; the orchestrator handles
121
+ // all normalisation including marker parsing, pre-send, and media routing.
122
+ const text = overrides?.text !== undefined ? overrides.text : runtime.asString(ctx.text || '');
123
+ const mediaUrl =
124
+ overrides?.mediaUrl !== undefined ? overrides.mediaUrl : runtime.asString(ctx.mediaUrl || '');
125
+ const mediaUrls = overrides?.mediaUrls !== undefined ? overrides.mediaUrls : ctx.mediaUrls;
126
+ const asVoice = overrides?.asVoice ?? ctx.asVoice === true;
127
+ const audioAsVoice = overrides?.audioAsVoice ?? ctx.audioAsVoice === true;
128
+ const type =
129
+ overrides?.type !== undefined
130
+ ? overrides.type
131
+ : runtime.asString(ctx.type || '').trim() || undefined;
132
+ const kind = overrides?.kind ?? ctx.kind;
133
+ const replyToId =
134
+ (overrides?.replyToId ??
135
+ runtime.asString(ctx.replyToId || ctx.replyToMessageId || '').trim()) ||
136
+ undefined;
137
+ const extra =
138
+ typeof ctx.extra === 'object' && ctx.extra !== null ? { ...ctx.extra } : undefined;
139
+ const downloadMedia = resolveDownloadMedia(
140
+ to,
141
+ overrides?.downloadMedia,
142
+ ctx.downloadMedia,
143
+ runtime,
189
144
  );
190
145
 
191
- const kind: 'text' | 'media' = hasMedia ? 'media' : 'text';
192
- logChannelSendEntry(runtime, {
146
+ return {
147
+ accountId: normalizeAccountId(ctx.accountId),
148
+ to,
149
+ text: text,
150
+ mediaUrl: mediaUrl,
151
+ mediaUrls,
152
+ asVoice,
153
+ audioAsVoice,
154
+ type,
193
155
  kind,
156
+ replyToId,
157
+ extra,
158
+ downloadMedia,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Single dispatch path for channelSendText / channelSendMedia.
164
+ * Both normalize first then branch media vs text via hasMedia.
165
+ * channelSendMedia passes mediaOverrides(ctx) to ensure media routing,
166
+ * channelSendText passes no overrides and relies on ctx content alone.
167
+ */
168
+ async function sendDispatch(
169
+ ctx: BncrChannelSendContext,
170
+ overrides?: {
171
+ text?: string;
172
+ mediaUrl?: string;
173
+ mediaUrls?: unknown;
174
+ asVoice?: boolean;
175
+ audioAsVoice?: boolean;
176
+ downloadMedia?: boolean;
177
+ type?: string;
178
+ kind?: string;
179
+ replyToId?: string;
180
+ },
181
+ ) {
182
+ await runtime.syncDebugFlag();
183
+ const {
194
184
  accountId,
195
185
  to,
196
- ctx,
197
- payload: {
198
- text: cleanText,
199
- mediaUrl: effectiveMediaUrl,
200
- mediaUrls: effectiveMediaUrls,
201
- asVoice,
202
- audioAsVoice,
203
- },
204
- });
186
+ text,
187
+ mediaUrl,
188
+ mediaUrls,
189
+ asVoice,
190
+ audioAsVoice,
191
+ type,
192
+ kind,
193
+ replyToId,
194
+ extra,
195
+ downloadMedia,
196
+ } = extractSendFields(ctx, overrides);
205
197
 
206
- if (hasMedia) {
207
- return sendBncrMedia({
208
- channelId: runtime.channelId,
198
+ runtime.logInfo(
199
+ 'outbound',
200
+ buildBncrDebugJsonMessage('send-raw', {
209
201
  accountId,
210
202
  to,
211
- text: cleanText,
212
- mediaUrl: effectiveMediaUrl,
213
- mediaUrls: effectiveMediaUrls,
214
- asVoice,
215
- audioAsVoice,
203
+ textLen: text.length,
204
+ hasMedia: Boolean(mediaUrl || (Array.isArray(mediaUrls) && mediaUrls.length)),
216
205
  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
- }
206
+ }),
207
+ { debugOnly: true },
208
+ );
209
+
210
+ const verified = runtime.resolveVerifiedTarget(to, accountId);
211
+ runtime.rememberSessionRoute(verified.sessionKey, accountId, verified.route);
228
212
 
229
- return sendBncrText({
230
- channelId: runtime.channelId,
213
+ await runtime.enqueueFromReply({
231
214
  accountId,
232
- to,
233
- text: cleanText,
234
- kind: normalizeReplyKind(ctx?.kind),
235
- replyToId,
236
- extra: Object.keys(extra).length > 0 ? extra : undefined,
215
+ sessionKey: verified.sessionKey,
216
+ route: verified.route,
217
+ payload: {
218
+ text: text,
219
+ mediaUrl: mediaUrl || undefined,
220
+ mediaUrls: Array.isArray(mediaUrls) && mediaUrls.length ? mediaUrls : undefined,
221
+ asVoice: asVoice || undefined,
222
+ audioAsVoice: audioAsVoice || undefined,
223
+ downloadMedia,
224
+ type,
225
+ extra,
226
+ kind: kind as 'tool' | 'block' | 'final' | undefined,
227
+ replyToId,
228
+ },
237
229
  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
230
  });
231
+
232
+ return {
233
+ channel: runtime.channelId,
234
+ messageId: randomUUID(),
235
+ chatId: verified.sessionKey,
236
+ };
243
237
  }
244
238
 
245
239
  /**
246
- * Durable dispatch: same logic but enqueues via channel message handoff.
240
+ * Durable channel.message path: same normalize, then enqueue handoff.
247
241
  */
248
242
  async function messageSendDispatch(
249
243
  ctx: BncrChannelSendContext,
250
- inputOverrides?: {
244
+ overrides?: {
251
245
  text?: string;
252
246
  mediaUrl?: string;
253
- mediaUrls?: string[];
247
+ mediaUrls?: unknown;
254
248
  asVoice?: boolean;
255
249
  audioAsVoice?: boolean;
256
250
  downloadMedia?: boolean;
257
251
  type?: string;
258
- kind?: ReplyPayloadInput['kind'];
252
+ kind?: string;
259
253
  replyToId?: string;
260
254
  },
261
255
  ) {
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
- );
256
+ const {
257
+ text,
258
+ mediaUrl,
259
+ mediaUrls,
260
+ asVoice,
261
+ audioAsVoice,
262
+ type,
263
+ kind,
264
+ replyToId,
265
+ extra,
266
+ downloadMedia,
267
+ } = extractSendFields(ctx, overrides);
276
268
 
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);
269
+ const entry = await enqueueChannelMessageHandoff(runtime, ctx, {
270
+ text: text,
271
+ mediaUrl: mediaUrl || '',
272
+ mediaUrls: Array.isArray(mediaUrls) && mediaUrls.length ? mediaUrls : undefined,
273
+ asVoice,
274
+ audioAsVoice,
275
+ downloadMedia,
276
+ type,
277
+ extra,
278
+ kind: kind as 'tool' | 'block' | 'final' | undefined,
279
+ replyToId,
280
+ });
290
281
  return buildBncrDurableQueuedResult({ entry });
291
282
  }
292
283
 
293
284
  return {
294
- channelSendText: sendDispatch,
295
- channelSendMedia: sendDispatch,
285
+ channelSendText: (ctx: BncrChannelSendContext) => sendDispatch(ctx),
286
+ channelSendMedia: (ctx: BncrChannelSendContext) => sendDispatch(ctx, mediaOverrides(ctx)),
296
287
 
297
- channelMessageSendText: async (ctx: BncrChannelSendContext) =>
298
- messageSendDispatch(ctx, { kind: normalizeReplyKind(ctx?.kind) }),
288
+ channelMessageSendText: async (ctx: BncrChannelSendContext) => messageSendDispatch(ctx),
299
289
 
300
290
  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
- }),
291
+ messageSendDispatch(ctx, mediaOverrides(ctx)),
309
292
 
310
293
  channelMessageSendPayload: async (ctx: BncrChannelSendContext) => {
311
294
  const p = ctx?.payload || {};
312
- if (!p || typeof p !== 'object')
295
+ if (!p || typeof p !== 'object') {
313
296
  throw new Error('bncr channel.message payload must be an object');
297
+ }
314
298
  return messageSendDispatch(ctx, {
315
299
  text: runtime.asString(p.text || p.message || p.caption || ''),
316
300
  mediaUrl: runtime.asString(p.mediaUrl || ''),
317
301
  mediaUrls: Array.isArray(p.mediaUrls) ? p.mediaUrls : undefined,
318
302
  asVoice: p.asVoice === true,
319
303
  audioAsVoice: p.audioAsVoice === true,
320
- downloadMedia: p.downloadMedia === true,
304
+ downloadMedia: p.downloadMedia as boolean | undefined,
321
305
  type: runtime.asString(p.type || '').trim() || undefined,
322
- kind: normalizeReplyKind(p.kind) ?? normalizeReplyKind(ctx?.kind),
306
+ kind: typeof p.kind === 'string' ? p.kind : ctx.kind,
323
307
  replyToId:
324
308
  runtime.asString(p.replyToId || ctx?.replyToId || ctx?.replyToMessageId || '').trim() ||
325
309
  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({