@xmoxmo/bncr 0.6.9 → 0.7.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmoxmo/bncr",
3
- "version": "0.6.9",
3
+ "version": "0.7.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,6 +27,7 @@
27
27
  "scripts": {
28
28
  "build": "npx -y esbuild@0.28.0 index.ts --bundle --platform=node --format=esm --target=node22 --packages=external --outfile=dist/index.js",
29
29
  "selfcheck": "node ./scripts/selfcheck.mjs",
30
+ "selfcheck:release": "node ./scripts/selfcheck.mjs --release",
30
31
  "test": "node --import ./tests/register-ts-hooks.mjs --test ./tests/**/*.test.mjs",
31
32
  "check-register-drift": "node ./scripts/check-register-drift.mjs",
32
33
  "check-pack": "node ./scripts/check-pack.mjs",
@@ -13,6 +13,7 @@ const requiredScriptFiles = [
13
13
  'scripts/check-pack.mjs',
14
14
  'scripts/check-register-drift.mjs',
15
15
  'scripts/selfcheck.mjs',
16
+ 'scripts/version-policy.mjs',
16
17
  ];
17
18
 
18
19
  const requiredSourceFiles = [
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { validateReleaseVersionPolicy, validateVersionSyntax } from './version-policy.mjs';
6
7
 
7
8
  const __filename = fileURLToPath(import.meta.url);
8
9
  const __dirname = path.dirname(__filename);
@@ -10,6 +11,7 @@ const root = path.resolve(__dirname, '..');
10
11
  const require = createRequire(import.meta.url);
11
12
 
12
13
  const requiredRootFiles = ['index.ts', 'openclaw.plugin.json'];
14
+ const requiredScriptFiles = ['scripts/version-policy.mjs'];
13
15
 
14
16
  const requiredSourceFiles = [
15
17
  'src/bootstrap/channel-plugin-runtime.ts',
@@ -213,7 +215,7 @@ const requiredSourceFiles = [
213
215
  'src/runtime/status-worker.ts',
214
216
  ];
215
217
 
216
- const requiredFiles = [...requiredRootFiles, ...requiredSourceFiles];
218
+ const requiredFiles = [...requiredRootFiles, ...requiredScriptFiles, ...requiredSourceFiles];
217
219
 
218
220
  const readPackageVersion = () => {
219
221
  const pkgPath = path.join(root, 'package.json');
@@ -238,17 +240,17 @@ const readNpmLatestVersion = (packageName) => {
238
240
  const requiredOpenClawSdkSubpaths = [
239
241
  'openclaw/plugin-sdk/channel-outbound',
240
242
  'openclaw/plugin-sdk/channel-ingress-runtime',
241
- 'openclaw/plugin-sdk/channel-message',
242
243
  'openclaw/plugin-sdk/routing',
243
244
  'openclaw/plugin-sdk/conversation-runtime',
244
245
  'openclaw/plugin-sdk/session-store-runtime',
245
246
  'openclaw/plugin-sdk/core',
246
247
  'openclaw/plugin-sdk/json-store',
247
248
  'openclaw/plugin-sdk/param-readers',
249
+ 'openclaw/plugin-sdk/media-runtime',
248
250
  'openclaw/plugin-sdk/security-runtime',
249
251
  'openclaw/plugin-sdk/status-helpers',
250
252
  'openclaw/plugin-sdk/tool-send',
251
- 'openclaw/plugin-sdk/command-auth',
253
+ 'openclaw/plugin-sdk/command-detection',
252
254
  'openclaw/plugin-sdk/reply-history',
253
255
  ];
254
256
 
@@ -266,120 +268,14 @@ const resolveOpenClawSdkSubpaths = () => {
266
268
  });
267
269
  };
268
270
 
269
- const validateVersionPolicy = (version, latestVersion) => {
270
- const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
271
- if (!match) {
272
- return {
273
- ok: false,
274
- reason: 'version must be strict semver x.y.z',
275
- version,
276
- };
277
- }
278
-
279
- const patch = Number.parseInt(match[3], 10);
280
- if (patch > 9) {
281
- return {
282
- ok: false,
283
- reason: 'patch version must stay within 0-9; bump minor instead',
284
- version,
285
- };
286
- }
287
-
288
- // Prevent jumping minor when current minor still has unused patch slots
289
- if (latestVersion) {
290
- const lm = latestVersion.match(/^(\d+)\.(\d+)\.(\d+)$/);
291
- if (lm) {
292
- const vMajor = Number.parseInt(match[1], 10);
293
- const vMinor = Number.parseInt(match[2], 10);
294
- const lMajor = Number.parseInt(lm[1], 10);
295
- const lMinor = Number.parseInt(lm[2], 10);
296
- const lPatch = Number.parseInt(lm[3], 10);
297
-
298
- // Version unchanged
299
- if (vMajor === lMajor && vMinor === lMinor && patch === lPatch) {
300
- return { ok: false, reason: `version unchanged: ${version}`, version };
301
- }
302
-
303
- // Downgrade: any component decreased
304
- if (
305
- vMajor < lMajor ||
306
- (vMajor === lMajor && vMinor < lMinor) ||
307
- (vMajor === lMajor && vMinor === lMinor && patch < lPatch)
308
- ) {
309
- return { ok: false, reason: `downgrade from ${latestVersion} to ${version}`, version };
310
- }
311
-
312
- // Patch bump: same major and minor, patch must increment by exactly +1
313
- if (vMajor === lMajor && vMinor === lMinor) {
314
- if (patch === lPatch + 1) return { ok: true, version };
315
- return {
316
- ok: false,
317
- reason: `patch jump from ${latestVersion} to ${version} (delta=${patch - lPatch}); expected ${lMajor}.${lMinor}.${lPatch + 1}`,
318
- version,
319
- };
320
- }
321
-
322
- // Minor bump: same major, minor + 1
323
- if (vMajor === lMajor && vMinor === lMinor + 1) {
324
- if (lPatch === 9 && patch === 0) return { ok: true, version };
325
- if (lPatch !== 9)
326
- return {
327
- ok: false,
328
- reason: `minor bumped from ${latestVersion} to ${version} but ${9 - lPatch} patch slots remain in ${lMajor}.${lMinor}; prefer ${lMajor}.${lMinor}.${lPatch + 1}`,
329
- version,
330
- };
331
- return {
332
- ok: false,
333
- reason: `minor bump must reset patch to 0; got ${vMajor}.${vMinor}.${patch}`,
334
- version,
335
- };
336
- }
337
-
338
- // Minor jumped by more than 1
339
- if (vMajor === lMajor && vMinor > lMinor + 1) {
340
- return {
341
- ok: false,
342
- reason: `minor jumped from ${latestVersion} to ${version}; expected ${lMajor}.${lMinor + 1}.0`,
343
- version,
344
- };
345
- }
346
-
347
- // Major bump: major + 1, requires previous minor fully exhausted
348
- if (vMajor === lMajor + 1) {
349
- if (lMinor === 9 && lPatch === 9 && vMinor === 0 && patch === 0)
350
- return { ok: true, version };
351
- if (lMinor !== 9 || lPatch !== 9)
352
- return {
353
- ok: false,
354
- reason: `major bumped from ${latestVersion} to ${version} but previous major series not exhausted; expected ${lMajor + 1}.0.0 after ${lMajor}.9.9`,
355
- version,
356
- };
357
- return {
358
- ok: false,
359
- reason: `major bump must reset to .0.0; got ${vMajor}.${vMinor}.${patch}`,
360
- version,
361
- };
362
- }
363
-
364
- // Major jumped by more than 1
365
- if (vMajor > lMajor + 1) {
366
- return {
367
- ok: false,
368
- reason: `major jumped from ${latestVersion} to ${version}; expected ${lMajor + 1}.0.0`,
369
- version,
370
- };
371
- }
372
- }
373
- }
374
-
375
- return { ok: true, version };
376
- };
377
-
378
271
  const missing = requiredFiles.filter((rel) => !fs.existsSync(path.join(root, rel)));
379
272
  const version = readPackageVersion();
380
273
  const packageName = '@xmoxmo/bncr';
381
- const latestVersion = readNpmLatestVersion(packageName);
382
- const versionPolicy = validateVersionPolicy(version, latestVersion);
274
+ const releaseMode = process.argv.includes('--release');
275
+ const latestVersion = releaseMode ? readNpmLatestVersion(packageName) : null;
276
+ const versionPolicy = releaseMode
277
+ ? validateReleaseVersionPolicy(version, latestVersion)
278
+ : validateVersionSyntax(version);
383
279
  const sdkSubpaths = resolveOpenClawSdkSubpaths();
384
280
  const missingSdkSubpaths = sdkSubpaths.filter((entry) => !entry.ok);
385
281
  const result = {
@@ -388,6 +284,8 @@ const result = {
388
284
  requiredCount: requiredFiles.length,
389
285
  missing,
390
286
  version,
287
+ versionMode: releaseMode ? 'release' : 'development',
288
+ latestVersion,
391
289
  versionPolicy,
392
290
  sdkSubpaths,
393
291
  };
@@ -0,0 +1,75 @@
1
+ export const validateVersionSyntax = (version) => {
2
+ const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
3
+ if (!match) {
4
+ return {
5
+ ok: false,
6
+ reason: 'version must be strict semver x.y.z',
7
+ version,
8
+ };
9
+ }
10
+
11
+ const patch = Number.parseInt(match[3], 10);
12
+ if (patch > 9) {
13
+ return {
14
+ ok: false,
15
+ reason: 'patch version must stay within 0-9; bump minor instead',
16
+ version,
17
+ };
18
+ }
19
+
20
+ return { ok: true, version };
21
+ };
22
+
23
+ export const validateReleaseVersionPolicy = (version, latestVersion) => {
24
+ const versionPolicy = validateVersionSyntax(version);
25
+ if (!versionPolicy.ok) return versionPolicy;
26
+
27
+ if (!latestVersion) {
28
+ return {
29
+ ok: false,
30
+ reason: 'cannot verify release version: npm latest version is unavailable',
31
+ version,
32
+ };
33
+ }
34
+
35
+ const latestMatch = latestVersion.match(/^(\d+)\.(\d+)\.(\d+)$/);
36
+ if (!latestMatch) {
37
+ return {
38
+ ok: false,
39
+ reason: `invalid npm latest version: ${latestVersion}`,
40
+ version,
41
+ };
42
+ }
43
+
44
+ const latestMajor = Number.parseInt(latestMatch[1], 10);
45
+ const latestMinor = Number.parseInt(latestMatch[2], 10);
46
+ const latestPatch = Number.parseInt(latestMatch[3], 10);
47
+ if (latestPatch > 9) {
48
+ return {
49
+ ok: false,
50
+ reason: `invalid npm latest version: patch must stay within 0-9 (${latestVersion})`,
51
+ version,
52
+ };
53
+ }
54
+
55
+ const expected =
56
+ latestPatch < 9
57
+ ? `${latestMajor}.${latestMinor}.${latestPatch + 1}`
58
+ : latestMinor < 9
59
+ ? `${latestMajor}.${latestMinor + 1}.0`
60
+ : `${latestMajor + 1}.0.0`;
61
+
62
+ if (version !== expected) {
63
+ return {
64
+ ok: false,
65
+ reason: `release version must be exactly one valid step after ${latestVersion}: expected ${expected}, got ${version}`,
66
+ version,
67
+ };
68
+ }
69
+
70
+ return {
71
+ ok: true,
72
+ mode: 'release',
73
+ version,
74
+ };
75
+ };
package/src/channel.ts CHANGED
@@ -231,6 +231,10 @@ class BncrBridgeRuntime {
231
231
  // Identity / lifecycle ----------------------------------------------------
232
232
  private api: OpenClawPluginApi;
233
233
 
234
+ getApi(): OpenClawPluginApi {
235
+ return this.api;
236
+ }
237
+
234
238
  private get runtimeApi(): OpenClawPluginApi {
235
239
  return this.api;
236
240
  }
@@ -3235,6 +3239,7 @@ export function createBncrChannelPlugin(getBridge: () => BncrBridgeRuntime) {
3235
3239
  return createBncrChannelPluginSurfaceGroup({
3236
3240
  channelId: CHANNEL_ID,
3237
3241
  sceneRegistry: getBridge().getSceneRegistry(),
3242
+ getApi: () => getBridge().getApi(),
3238
3243
  getMessageSendBridge: bridgeGroup.getMessageSendBridge,
3239
3244
  getOutboundBridge: bridgeGroup.getOutboundBridge,
3240
3245
  getMessagingBridge: bridgeGroup.getMessagingBridge,
@@ -1,4 +1,4 @@
1
- import { hasControlCommand } from 'openclaw/plugin-sdk/command-auth';
1
+ import { hasControlCommand } from 'openclaw/plugin-sdk/command-detection';
2
2
  import { emitBncrLogLine } from '../../core/logging.ts';
3
3
  import { resolveBncrChannelPolicy } from '../../core/policy.ts';
4
4
  import { formatDisplayScope } from '../../core/targets.ts';
@@ -1,5 +1,5 @@
1
1
  import fs from 'node:fs';
2
- import { hasControlCommand } from 'openclaw/plugin-sdk/command-auth';
2
+ import { hasControlCommand } from 'openclaw/plugin-sdk/command-detection';
3
3
  import {
4
4
  formatDisplayScope,
5
5
  normalizeInboundSessionKey,
@@ -271,7 +271,11 @@ export async function dispatchBncrInbound(params: {
271
271
  mediaContentType: mediaContentType || mimeType,
272
272
  historyLimit: resolvedHistoryLimit,
273
273
  });
274
- if (historyKey) {
274
+ // A silent history flush repurposes the current turn as a context carrier
275
+ // and suppresses its outbound. Only a turn we would not reply to anyway
276
+ // may carry the flush; a reply-expected turn must reach the channel, and
277
+ // its backlog is flushed by the outbound path after the reply instead.
278
+ if (historyKey && !initialShouldDispatch && !forceSilentHistoryFlush) {
275
279
  const entries = conversationHistories.get(historyKey);
276
280
  if (entries && resolvedHistoryLimit > 0 && entries.length >= resolvedHistoryLimit) {
277
281
  const sceneForHistory = sceneRegistry.get(buildSceneKey(parsed));
@@ -279,6 +283,10 @@ export async function dispatchBncrInbound(params: {
279
283
  if (historyForce) {
280
284
  silentHistoryFlush = true;
281
285
  shouldDispatch = true;
286
+ emitBncrLogLine(
287
+ 'info',
288
+ `history silent flush|key=${historyKey}|limit=${resolvedHistoryLimit}|entries=${entries.length}|carrier=${msgId ?? '-'}`,
289
+ );
282
290
  }
283
291
  }
284
292
  }
@@ -1,5 +1,9 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { canonicalizeBase64, estimateBase64DecodedBytes } from 'openclaw/plugin-sdk/media-runtime';
4
+
5
+ export const OPENCLAW_SEND_BUFFER_MEDIA_URL = 'buffer://message-send/attachment';
6
+ export const OPENCLAW_OUTBOUND_MEDIA_MAX_BYTES = 50 * 1024 * 1024;
3
7
 
4
8
  type RuntimeMediaLoaded = {
5
9
  buffer: Buffer;
@@ -39,6 +43,114 @@ type RuntimeApiHolder = {
39
43
 
40
44
  export type OpenClawLoadedMedia = RuntimeMediaLoaded;
41
45
 
46
+ type OpenClawSendBufferDecodeResult = {
47
+ buffer: Buffer;
48
+ mimeType?: string;
49
+ fileName?: string;
50
+ };
51
+
52
+ function normalizeOptionalString(value: string | undefined): string | undefined {
53
+ const normalized = String(value || '').trim();
54
+ return normalized || undefined;
55
+ }
56
+
57
+ function isPathInsideRoot(candidate: string, root: string): boolean {
58
+ const relative = path.relative(root, candidate);
59
+ return (
60
+ relative === '' ||
61
+ (relative !== '..' && !path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`))
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Translate a sandbox container workspace path into an authorized host path.
67
+ *
68
+ * `extra.path` bypasses host-side sandbox staging, so a path such as
69
+ * `/workspace/file.js` reaches the plugin unchanged. Resolve it only against
70
+ * roots supplied by the host and prefer the most specific matching root.
71
+ */
72
+ function resolveContainerWorkspaceMediaPath(
73
+ mediaUrl: string,
74
+ localRoots?: readonly string[],
75
+ ): string {
76
+ if (!localRoots?.length || !/^\/workspace(?:\/|$)/.test(mediaUrl)) {
77
+ return mediaUrl;
78
+ }
79
+ if (existsSync(mediaUrl)) {
80
+ return mediaUrl;
81
+ }
82
+
83
+ const relativePath = mediaUrl.slice('/workspace'.length).replace(/^\/+/, '');
84
+ if (!relativePath) {
85
+ return mediaUrl;
86
+ }
87
+
88
+ let bestMatch: { root: string; candidate: string } | undefined;
89
+ for (const root of localRoots) {
90
+ const resolvedRoot = path.resolve(root);
91
+ const candidate = path.resolve(resolvedRoot, relativePath);
92
+ if (!isPathInsideRoot(candidate, resolvedRoot) || !existsSync(candidate)) {
93
+ continue;
94
+ }
95
+ if (!bestMatch || resolvedRoot.length > bestMatch.root.length) {
96
+ bestMatch = { root: resolvedRoot, candidate };
97
+ }
98
+ }
99
+
100
+ return bestMatch?.candidate ?? mediaUrl;
101
+ }
102
+
103
+ export function isOpenClawSendBufferMediaUrl(mediaUrl: string): boolean {
104
+ return String(mediaUrl || '').trim() === OPENCLAW_SEND_BUFFER_MEDIA_URL;
105
+ }
106
+
107
+ export function decodeOpenClawSendBuffer(params: {
108
+ buffer: string;
109
+ contentType?: string;
110
+ mimeType?: string;
111
+ filename?: string;
112
+ fileName?: string;
113
+ maxBytes?: number;
114
+ }): OpenClawSendBufferDecodeResult {
115
+ const raw = String(params.buffer || '').trim();
116
+ const match = /^data:([^;,\s]+)(;(?!base64)[^,;\s]+)*;base64,(.*)$/is.exec(raw);
117
+ const encoded = match ? match[3] : raw;
118
+ if (!encoded.trim()) {
119
+ throw new Error('send buffer base64 decoded to empty buffer');
120
+ }
121
+ const canonical = canonicalizeBase64(encoded);
122
+ if (!canonical) {
123
+ throw new Error('send buffer has invalid base64 data');
124
+ }
125
+
126
+ const maxBytes = params.maxBytes ?? OPENCLAW_OUTBOUND_MEDIA_MAX_BYTES;
127
+ const estimatedBytes = estimateBase64DecodedBytes(canonical);
128
+ if (estimatedBytes > maxBytes) {
129
+ throw new Error(
130
+ `send buffer media too large: estimated ${estimatedBytes} bytes exceeds ${maxBytes} bytes`,
131
+ );
132
+ }
133
+
134
+ const buffer = Buffer.from(canonical, 'base64');
135
+ if (!buffer.length) {
136
+ throw new Error('send buffer base64 decoded to empty buffer');
137
+ }
138
+ if (buffer.byteLength > maxBytes) {
139
+ throw new Error(
140
+ `send buffer media too large: decoded ${buffer.byteLength} bytes exceeds ${maxBytes} bytes`,
141
+ );
142
+ }
143
+
144
+ return {
145
+ buffer,
146
+ mimeType:
147
+ normalizeOptionalString(params.contentType) ??
148
+ normalizeOptionalString(params.mimeType) ??
149
+ normalizeOptionalString(match?.[1]),
150
+ fileName: normalizeOptionalString(params.filename) ?? normalizeOptionalString(params.fileName),
151
+ };
152
+ }
153
+
42
154
  export function isOpenClawRemoteHttpMediaUrl(mediaUrl: string): boolean {
43
155
  return /^https?:\/\//i.test(String(mediaUrl || '').trim());
44
156
  }
@@ -50,6 +162,8 @@ export function isOpenClawRemoteHttpMediaUrl(mediaUrl: string): boolean {
50
162
  */
51
163
  function resolveRelativeMediaPath(mediaUrl: string, localRoots?: readonly string[]): string {
52
164
  if (!mediaUrl || !localRoots?.length) return mediaUrl;
165
+ const containerWorkspacePath = resolveContainerWorkspaceMediaPath(mediaUrl, localRoots);
166
+ if (containerWorkspacePath !== mediaUrl) return containerWorkspacePath;
53
167
  if (path.isAbsolute(mediaUrl)) return mediaUrl;
54
168
  // HTTP / file:// / data: / ~ paths are handled elsewhere
55
169
  if (/^(https?|file|data):/i.test(mediaUrl) || mediaUrl.startsWith('~')) return mediaUrl;
@@ -1,4 +1,4 @@
1
- import type { ChannelMessageSendResult } from 'openclaw/plugin-sdk/channel-message';
1
+ import type { ChannelMessageSendResult } from 'openclaw/plugin-sdk/channel-outbound';
2
2
  import type { GatewayRequestHandlerOptions } from 'openclaw/plugin-sdk/core';
3
3
  import type { BncrRecentOutboundEntry, BncrRoute } from '../core/types.ts';
4
4
  import type { ReplyPayloadInput } from '../messaging/outbound/reply-enqueue.ts';
@@ -1,4 +1,4 @@
1
- import type { ChannelMessageSendResult } from 'openclaw/plugin-sdk/channel-message';
1
+ import type { ChannelMessageSendResult } from 'openclaw/plugin-sdk/channel-outbound';
2
2
  import { Type } from 'typebox';
3
3
  import { listAccountIds, normalizeAccountId, resolveAccount } from '../core/accounts.ts';
4
4
  import { BncrConfigSchema } from '../core/config-schema.ts';
@@ -6,6 +6,12 @@ import { resolveBncrChannelPolicy } from '../core/policy.ts';
6
6
  import type { BncrRoute } from '../core/types.ts';
7
7
  import type { ReplyPayloadInput } from '../messaging/outbound/reply-enqueue.ts';
8
8
  import type { OutboundReplyTargetPolicy } from '../messaging/outbound/reply-target-policy.ts';
9
+ import {
10
+ decodeOpenClawSendBuffer,
11
+ isOpenClawSendBufferMediaUrl,
12
+ OPENCLAW_OUTBOUND_MEDIA_MAX_BYTES,
13
+ saveOpenClawChannelMediaBuffer,
14
+ } from '../openclaw/media-runtime.ts';
9
15
  import type { OpenClawChannelToolSend, openClawJsonResult } from '../openclaw/sdk-helpers.ts';
10
16
  import { readOpenClawStringParam } from '../openclaw/sdk-helpers.ts';
11
17
  import type { BncrBridgeCallBridge } from './bridge-call.ts';
@@ -68,6 +74,7 @@ const BNCR_MESSAGE_TOOL_SCHEMA: BncrMessageToolSchemaContribution = {
68
74
  export function createBncrChannelPluginSurfaceGroup(runtime: {
69
75
  channelId: string;
70
76
  sceneRegistry: Map<string, BncrSceneRecord>;
77
+ getApi: () => Parameters<typeof saveOpenClawChannelMediaBuffer>[0];
71
78
  getMessageSendBridge: () => {
72
79
  channelMessageSendText: (
73
80
  ctx: Record<string, unknown>,
@@ -222,20 +229,49 @@ export function createBncrChannelPluginSurfaceGroup(runtime: {
222
229
  }
223
230
 
224
231
  const to = readOpenClawStringParam(paramsObj, 'to', { required: true });
225
- const toolMediaUrl =
232
+ const rawBuffer = typeof paramsObj.buffer === 'string' ? paramsObj.buffer : '';
233
+ const rawMediaUrl =
226
234
  (typeof paramsObj.mediaUrl === 'string' ? paramsObj.mediaUrl : '') ||
227
235
  (typeof paramsObj.media === 'string' ? paramsObj.media : '') ||
228
236
  (typeof paramsObj.path === 'string' ? paramsObj.path : '') ||
229
237
  (typeof paramsObj.filePath === 'string' ? paramsObj.filePath : '');
238
+ const rawMediaUrls = paramsObj.mediaUrls;
239
+ const declaredMediaUrls = Array.isArray(rawMediaUrls)
240
+ ? rawMediaUrls.filter((u): u is string => typeof u === 'string')
241
+ : undefined;
242
+ const hasExplicitMedia = [rawMediaUrl, ...(declaredMediaUrls ?? [])].some(
243
+ (mediaUrl) => Boolean(mediaUrl) && !isOpenClawSendBufferMediaUrl(mediaUrl),
244
+ );
245
+ let toolMediaUrl = rawMediaUrl;
246
+ let mediaUrls = declaredMediaUrls;
247
+ if (rawBuffer && !hasExplicitMedia) {
248
+ const decoded = decodeOpenClawSendBuffer({
249
+ buffer: rawBuffer,
250
+ contentType:
251
+ typeof paramsObj.contentType === 'string' ? paramsObj.contentType : undefined,
252
+ mimeType: typeof paramsObj.mimeType === 'string' ? paramsObj.mimeType : undefined,
253
+ filename: typeof paramsObj.filename === 'string' ? paramsObj.filename : undefined,
254
+ fileName: typeof paramsObj.fileName === 'string' ? paramsObj.fileName : undefined,
255
+ maxBytes: OPENCLAW_OUTBOUND_MEDIA_MAX_BYTES,
256
+ });
257
+ const saved = await saveOpenClawChannelMediaBuffer(
258
+ runtime.getApi(),
259
+ decoded.buffer,
260
+ decoded.mimeType,
261
+ 'outbound',
262
+ OPENCLAW_OUTBOUND_MEDIA_MAX_BYTES,
263
+ decoded.fileName,
264
+ );
265
+ toolMediaUrl = saved.path;
266
+ mediaUrls = declaredMediaUrls?.map((mediaUrl) =>
267
+ isOpenClawSendBufferMediaUrl(mediaUrl) ? saved.path : mediaUrl,
268
+ );
269
+ }
230
270
  const rawExtra = paramsObj.extra;
231
271
  const extra =
232
272
  rawExtra && typeof rawExtra === 'object' && !Array.isArray(rawExtra)
233
273
  ? (rawExtra as Record<string, unknown>)
234
274
  : undefined;
235
- const rawMediaUrls = paramsObj.mediaUrls;
236
- const mediaUrls = Array.isArray(rawMediaUrls)
237
- ? rawMediaUrls.filter((u): u is string => typeof u === 'string')
238
- : undefined;
239
275
 
240
276
  const toolMessage = readOpenClawStringParam(paramsObj, 'message', { allowEmpty: true }) ?? '';
241
277
  const toolCaption = readOpenClawStringParam(paramsObj, 'caption', { allowEmpty: true }) ?? '';
@@ -1,4 +1,4 @@
1
- import { hasControlCommand } from 'openclaw/plugin-sdk/command-auth';
1
+ import { hasControlCommand } from 'openclaw/plugin-sdk/command-detection';
2
2
  import { resolveAccount } from '../core/accounts.ts';
3
3
  import { checkBncrMessageGate } from '../messaging/inbound/gate.ts';
4
4
  import {
@@ -1,4 +1,4 @@
1
- import type { ChannelMessageSendResult } from 'openclaw/plugin-sdk/channel-message';
1
+ import type { ChannelMessageSendResult } from 'openclaw/plugin-sdk/channel-outbound';
2
2
  import type { BncrChannelSendContext } from './channel-runtime-types.ts';
3
3
 
4
4
  export type BncrMessageSendBridge = {