@xmoxmo/bncr 0.5.5 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) 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 +114 -37
  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 +38 -15
  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/native-command.ts +7 -3
  32. package/src/messaging/inbound/parse.ts +1 -31
  33. package/src/messaging/inbound/scene-admin.ts +181 -5
  34. package/src/messaging/outbound/diagnostics.ts +1 -6
  35. package/src/messaging/outbound/durable-queue-adapter.ts +17 -11
  36. package/src/messaging/outbound/marker-parser.ts +188 -0
  37. package/src/messaging/outbound/media-dedupe.ts +2 -6
  38. package/src/messaging/outbound/media.ts +6 -20
  39. package/src/messaging/outbound/normalize-outbound-send.ts +327 -0
  40. package/src/messaging/outbound/queue-selectors.ts +1 -5
  41. package/src/messaging/outbound/reply-enqueue-media.ts +4 -0
  42. package/src/messaging/outbound/reply-enqueue.ts +130 -57
  43. package/src/messaging/outbound/session-route.ts +6 -11
  44. package/src/messaging/outbound/target-resolver.ts +1 -6
  45. package/src/openclaw/routing-runtime.ts +0 -11
  46. package/src/openclaw/sdk-helpers.ts +0 -2
  47. package/src/plugin/bridge-file-transfer-push-facade.ts +8 -6
  48. package/src/plugin/bridge-media-facade.ts +1 -1
  49. package/src/plugin/channel-components.ts +0 -13
  50. package/src/plugin/channel-plugin-surface-group.ts +57 -40
  51. package/src/plugin/channel-runtime-builders-delivery.ts +3 -0
  52. package/src/plugin/channel-runtime-types.ts +10 -24
  53. package/src/plugin/channel-send.ts +241 -154
  54. package/src/plugin/channel-utils.ts +8 -29
  55. package/src/plugin/connection-handlers-helpers.ts +0 -15
  56. package/src/plugin/connection-handlers.ts +6 -5
  57. package/src/plugin/file-inbound-abort.ts +5 -0
  58. package/src/plugin/file-inbound-chunk.ts +5 -0
  59. package/src/plugin/file-inbound-complete.ts +5 -0
  60. package/src/plugin/file-transfer-orchestrator.ts +105 -3
  61. package/src/plugin/file-transfer-setup.ts +1 -1
  62. package/src/plugin/media-dedupe-runtime.ts +1 -6
  63. package/src/plugin/media-orchestrators-runtime-group.ts +2 -0
  64. package/src/plugin/message-send.ts +7 -8
  65. package/src/plugin/outbound.ts +6 -22
  66. package/src/plugin/outbox-drain-loop.ts +2 -2
  67. package/src/plugin/outbox-file-push-flow.ts +7 -7
  68. package/src/plugin/outbox-text-push-flow.ts +1 -1
  69. package/src/plugin/target-runtime.ts +1 -6
  70. package/src/runtime/outbound-ack-timeout.ts +1 -8
  71. package/src/runtime/outbox-transitions.ts +1 -4
  72. package/src/runtime/status-worker.ts +1 -9
  73. package/src/messaging/outbound/build-send-action.ts +0 -130
  74. package/src/messaging/outbound/durable-message-adapter.ts +0 -121
  75. package/src/messaging/outbound/send-params.ts +0 -81
  76. package/src/messaging/outbound/send.ts +0 -121
  77. package/src/openclaw/ingress-runtime.ts +0 -25
  78. package/src/plugin/channel-send-runtime-group.ts +0 -37
@@ -3,12 +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
+
6
7
  import type { ReplyPayloadInput } from '../messaging/outbound/reply-enqueue.ts';
7
- import { sendBncrMedia, sendBncrText } from '../messaging/outbound/send.ts';
8
8
  import type { BncrChannelSendContext, BncrVerifiedTarget } from './channel-runtime-types.ts';
9
9
 
10
- function normalizeReplyKind(value: unknown): ReplyPayloadInput['kind'] {
11
- 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);
12
38
  }
13
39
 
14
40
  export type BncrChannelSendRuntime = {
@@ -26,54 +52,10 @@ export type BncrChannelSendRuntime = {
26
52
  mediaLocalRoots?: readonly string[];
27
53
  }) => Promise<void>;
28
54
  listOutboxEntries: () => OutboxEntry[];
55
+ /** Resolve downloadMedia from scene config: marker > scene > global > default(false). */
56
+ resolveSceneDownloadMedia?: (to: string) => boolean | undefined;
29
57
  };
30
58
 
31
- function resolveChannelSendReplyToId(
32
- asString: BncrChannelSendRuntime['asString'],
33
- ctx: BncrChannelSendContext,
34
- ) {
35
- return asString(ctx?.replyToId || ctx?.replyToMessageId || '').trim() || undefined;
36
- }
37
-
38
- function logChannelSendEntry(
39
- runtime: BncrChannelSendRuntime,
40
- args: {
41
- kind: 'text' | 'media';
42
- accountId: string;
43
- to: string;
44
- ctx: BncrChannelSendContext;
45
- payload: {
46
- text: string;
47
- mediaUrl: string;
48
- mediaUrls?: string[];
49
- asVoice?: boolean;
50
- audioAsVoice?: boolean;
51
- };
52
- },
53
- ) {
54
- runtime.logInfo(
55
- 'outbound',
56
- buildBncrDebugJsonMessage(`send-entry:${args.kind}`, {
57
- accountId: args.accountId,
58
- to: args.to,
59
- text: args.payload.text,
60
- mediaUrl: args.payload.mediaUrl,
61
- mediaUrls: args.payload.mediaUrls,
62
- asVoice: args.payload.asVoice,
63
- audioAsVoice: args.payload.audioAsVoice,
64
- sessionKey: runtime.asString(args.ctx?.sessionKey || ''),
65
- mirrorSessionKey: runtime.asString(args.ctx?.mirror?.sessionKey || ''),
66
- rawCtx: {
67
- to: args.ctx?.to,
68
- accountId: args.ctx?.accountId,
69
- threadId: args.ctx?.threadId,
70
- replyToId: args.ctx?.replyToId,
71
- },
72
- }),
73
- { debugOnly: true },
74
- );
75
- }
76
-
77
59
  async function enqueueChannelMessageHandoff(
78
60
  runtime: BncrChannelSendRuntime,
79
61
  ctx: BncrChannelSendContext,
@@ -98,129 +80,234 @@ async function enqueueChannelMessageHandoff(
98
80
  return entries[entries.length - 1];
99
81
  }
100
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
+
101
88
  export function createBncrChannelSendRuntime(runtime: BncrChannelSendRuntime) {
102
- return {
103
- channelSendText: async (ctx: BncrChannelSendContext) => {
104
- await runtime.syncDebugFlag();
105
- const accountId = normalizeAccountId(ctx.accountId);
106
- const to = runtime.asString(ctx.to || '').trim();
107
- const replyToId = resolveChannelSendReplyToId(runtime.asString, ctx);
108
-
109
- logChannelSendEntry(runtime, {
110
- kind: 'text',
111
- accountId,
112
- to,
113
- ctx,
114
- payload: {
115
- text: runtime.asString(ctx?.text || ''),
116
- mediaUrl: runtime.asString(ctx?.mediaUrl || ''),
117
- },
118
- });
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
+ }
119
99
 
120
- return sendBncrText({
121
- channelId: runtime.channelId,
122
- accountId,
123
- to,
124
- text: runtime.asString(ctx.text || ''),
125
- kind: normalizeReplyKind(ctx?.kind),
126
- replyToId,
127
- mediaLocalRoots: ctx.mediaLocalRoots,
128
- resolveVerifiedTarget: (targetTo, targetAccountId) =>
129
- runtime.resolveVerifiedTarget(targetTo, targetAccountId),
130
- rememberSessionRoute: (sessionKey, routeAccountId, route) =>
131
- runtime.rememberSessionRoute(sessionKey, routeAccountId, route),
132
- enqueueFromReply: (args) => runtime.enqueueFromReply(args),
133
- createMessageId: () => randomUUID(),
134
- });
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
+ } {
117
+ const to = runtime.asString(ctx.to || '').trim();
118
+
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,
144
+ );
145
+
146
+ return {
147
+ accountId: normalizeAccountId(ctx.accountId),
148
+ to,
149
+ text: text,
150
+ mediaUrl: mediaUrl,
151
+ mediaUrls,
152
+ asVoice,
153
+ audioAsVoice,
154
+ type,
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;
135
180
  },
181
+ ) {
182
+ await runtime.syncDebugFlag();
183
+ const {
184
+ accountId,
185
+ to,
186
+ text,
187
+ mediaUrl,
188
+ mediaUrls,
189
+ asVoice,
190
+ audioAsVoice,
191
+ type,
192
+ kind,
193
+ replyToId,
194
+ extra,
195
+ downloadMedia,
196
+ } = extractSendFields(ctx, overrides);
136
197
 
137
- channelSendMedia: async (ctx: BncrChannelSendContext) => {
138
- await runtime.syncDebugFlag();
139
- const accountId = normalizeAccountId(ctx.accountId);
140
- const to = runtime.asString(ctx.to || '').trim();
141
- const asVoice = ctx?.asVoice === true;
142
- const audioAsVoice = ctx?.audioAsVoice === true;
143
- const type = runtime.asString(ctx?.type || '').trim() || undefined;
144
- const replyToId = resolveChannelSendReplyToId(runtime.asString, ctx);
145
-
146
- logChannelSendEntry(runtime, {
147
- kind: 'media',
198
+ runtime.logInfo(
199
+ 'outbound',
200
+ buildBncrDebugJsonMessage('send-raw', {
148
201
  accountId,
149
202
  to,
150
- ctx,
151
- payload: {
152
- text: runtime.asString(ctx?.text || ''),
153
- mediaUrl: runtime.asString(ctx?.mediaUrl || ''),
154
- mediaUrls: Array.isArray(ctx?.mediaUrls) ? ctx.mediaUrls : undefined,
155
- asVoice,
156
- audioAsVoice,
157
- },
158
- });
203
+ textLen: text.length,
204
+ hasMedia: Boolean(mediaUrl || (Array.isArray(mediaUrls) && mediaUrls.length)),
205
+ downloadMedia,
206
+ }),
207
+ { debugOnly: true },
208
+ );
159
209
 
160
- return sendBncrMedia({
161
- channelId: runtime.channelId,
162
- accountId,
163
- to,
164
- text: runtime.asString(ctx.text || ''),
165
- mediaUrl: runtime.asString(ctx.mediaUrl || ''),
166
- mediaUrls: Array.isArray(ctx?.mediaUrls) ? ctx.mediaUrls : undefined,
167
- asVoice,
168
- audioAsVoice,
210
+ const verified = runtime.resolveVerifiedTarget(to, accountId);
211
+ runtime.rememberSessionRoute(verified.sessionKey, accountId, verified.route);
212
+
213
+ await runtime.enqueueFromReply({
214
+ accountId,
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,
169
224
  type,
170
- kind: normalizeReplyKind(ctx?.kind),
225
+ extra,
226
+ kind: kind as 'tool' | 'block' | 'final' | undefined,
171
227
  replyToId,
172
- mediaLocalRoots: ctx.mediaLocalRoots,
173
- resolveVerifiedTarget: (targetTo, targetAccountId) =>
174
- runtime.resolveVerifiedTarget(targetTo, targetAccountId),
175
- rememberSessionRoute: (sessionKey, routeAccountId, route) =>
176
- runtime.rememberSessionRoute(sessionKey, routeAccountId, route),
177
- enqueueFromReply: (args) => runtime.enqueueFromReply(args),
178
- createMessageId: () => randomUUID(),
179
- });
180
- },
228
+ },
229
+ mediaLocalRoots: ctx.mediaLocalRoots,
230
+ });
181
231
 
182
- channelMessageSendText: async (ctx: BncrChannelSendContext) => {
183
- const entry = await enqueueChannelMessageHandoff(runtime, ctx, {
184
- text: runtime.asString(ctx.text || ''),
185
- kind: normalizeReplyKind(ctx?.kind),
186
- replyToId: resolveChannelSendReplyToId(runtime.asString, ctx),
187
- });
188
- return buildBncrDurableQueuedResult({ entry });
189
- },
232
+ return {
233
+ channel: runtime.channelId,
234
+ messageId: randomUUID(),
235
+ chatId: verified.sessionKey,
236
+ };
237
+ }
190
238
 
191
- channelMessageSendMedia: async (ctx: BncrChannelSendContext) => {
192
- const entry = await enqueueChannelMessageHandoff(runtime, ctx, {
193
- text: runtime.asString(ctx.text || ''),
194
- mediaUrl: runtime.asString(ctx.mediaUrl || ''),
195
- mediaUrls: Array.isArray(ctx?.mediaUrls) ? ctx.mediaUrls : undefined,
196
- asVoice: ctx?.asVoice === true,
197
- audioAsVoice: ctx?.audioAsVoice === true,
198
- type: runtime.asString(ctx?.type || '').trim() || undefined,
199
- kind: normalizeReplyKind(ctx?.kind),
200
- replyToId: resolveChannelSendReplyToId(runtime.asString, ctx),
201
- });
202
- return buildBncrDurableQueuedResult({ entry });
239
+ /**
240
+ * Durable channel.message path: same normalize, then enqueue handoff.
241
+ */
242
+ async function messageSendDispatch(
243
+ ctx: BncrChannelSendContext,
244
+ overrides?: {
245
+ text?: string;
246
+ mediaUrl?: string;
247
+ mediaUrls?: unknown;
248
+ asVoice?: boolean;
249
+ audioAsVoice?: boolean;
250
+ downloadMedia?: boolean;
251
+ type?: string;
252
+ kind?: string;
253
+ replyToId?: string;
203
254
  },
255
+ ) {
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);
268
+
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
+ });
281
+ return buildBncrDurableQueuedResult({ entry });
282
+ }
283
+
284
+ return {
285
+ channelSendText: (ctx: BncrChannelSendContext) => sendDispatch(ctx),
286
+ channelSendMedia: (ctx: BncrChannelSendContext) => sendDispatch(ctx, mediaOverrides(ctx)),
287
+
288
+ channelMessageSendText: async (ctx: BncrChannelSendContext) => messageSendDispatch(ctx),
289
+
290
+ channelMessageSendMedia: async (ctx: BncrChannelSendContext) =>
291
+ messageSendDispatch(ctx, mediaOverrides(ctx)),
204
292
 
205
293
  channelMessageSendPayload: async (ctx: BncrChannelSendContext) => {
206
- const payload = ctx?.payload || {};
207
- if (!payload || typeof payload !== 'object') {
294
+ const p = ctx?.payload || {};
295
+ if (!p || typeof p !== 'object') {
208
296
  throw new Error('bncr channel.message payload must be an object');
209
297
  }
210
- const entry = await enqueueChannelMessageHandoff(runtime, ctx, {
211
- text: runtime.asString(payload.text || payload.message || payload.caption || ''),
212
- mediaUrl: runtime.asString(payload.mediaUrl || ''),
213
- mediaUrls: Array.isArray(payload.mediaUrls) ? payload.mediaUrls : undefined,
214
- asVoice: payload.asVoice === true,
215
- audioAsVoice: payload.audioAsVoice === true,
216
- type: runtime.asString(payload.type || '').trim() || undefined,
217
- kind: normalizeReplyKind(payload.kind),
298
+ return messageSendDispatch(ctx, {
299
+ text: runtime.asString(p.text || p.message || p.caption || ''),
300
+ mediaUrl: runtime.asString(p.mediaUrl || ''),
301
+ mediaUrls: Array.isArray(p.mediaUrls) ? p.mediaUrls : undefined,
302
+ asVoice: p.asVoice === true,
303
+ audioAsVoice: p.audioAsVoice === true,
304
+ downloadMedia: p.downloadMedia as boolean | undefined,
305
+ type: runtime.asString(p.type || '').trim() || undefined,
306
+ kind: typeof p.kind === 'string' ? p.kind : ctx.kind,
218
307
  replyToId:
219
- runtime
220
- .asString(payload.replyToId || ctx?.replyToId || ctx?.replyToMessageId || '')
221
- .trim() || undefined,
308
+ runtime.asString(p.replyToId || ctx?.replyToId || ctx?.replyToMessageId || '').trim() ||
309
+ undefined,
222
310
  });
223
- return buildBncrDurableQueuedResult({ entry });
224
311
  },
225
312
  };
226
313
  }
@@ -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({