@xmoxmo/bncr 0.6.8 → 0.7.0

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.8",
3
+ "version": "0.7.0",
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');
@@ -245,6 +247,7 @@ const requiredOpenClawSdkSubpaths = [
245
247
  'openclaw/plugin-sdk/core',
246
248
  'openclaw/plugin-sdk/json-store',
247
249
  'openclaw/plugin-sdk/param-readers',
250
+ 'openclaw/plugin-sdk/media-runtime',
248
251
  'openclaw/plugin-sdk/security-runtime',
249
252
  'openclaw/plugin-sdk/status-helpers',
250
253
  'openclaw/plugin-sdk/tool-send',
@@ -266,120 +269,14 @@ const resolveOpenClawSdkSubpaths = () => {
266
269
  });
267
270
  };
268
271
 
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
272
  const missing = requiredFiles.filter((rel) => !fs.existsSync(path.join(root, rel)));
379
273
  const version = readPackageVersion();
380
274
  const packageName = '@xmoxmo/bncr';
381
- const latestVersion = readNpmLatestVersion(packageName);
382
- const versionPolicy = validateVersionPolicy(version, latestVersion);
275
+ const releaseMode = process.argv.includes('--release');
276
+ const latestVersion = releaseMode ? readNpmLatestVersion(packageName) : null;
277
+ const versionPolicy = releaseMode
278
+ ? validateReleaseVersionPolicy(version, latestVersion)
279
+ : validateVersionSyntax(version);
383
280
  const sdkSubpaths = resolveOpenClawSdkSubpaths();
384
281
  const missingSdkSubpaths = sdkSubpaths.filter((entry) => !entry.ok);
385
282
  const result = {
@@ -388,6 +285,8 @@ const result = {
388
285
  requiredCount: requiredFiles.length,
389
286
  missing,
390
287
  version,
288
+ versionMode: releaseMode ? 'release' : 'development',
289
+ latestVersion,
391
290
  versionPolicy,
392
291
  sdkSubpaths,
393
292
  };
@@ -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
@@ -201,6 +201,7 @@ import {
201
201
  createBncrOutboxDiagnosticsHelpers,
202
202
  createBncrRuntimeAckObservabilityBuilder,
203
203
  } from './plugin/runtime-diagnostics-snapshot.ts';
204
+ import { buildRouteSceneKey } from './plugin/scene-registry.ts';
204
205
  import { BNCR_SETUP_SURFACE } from './plugin/setup.ts';
205
206
  import type { BncrHistoryShardRow } from './plugin/sqlite-state.ts';
206
207
  import { createBncrStateTransientRuntimeGroup } from './plugin/state-transient-runtime-group.ts';
@@ -230,6 +231,10 @@ class BncrBridgeRuntime {
230
231
  // Identity / lifecycle ----------------------------------------------------
231
232
  private api: OpenClawPluginApi;
232
233
 
234
+ getApi(): OpenClawPluginApi {
235
+ return this.api;
236
+ }
237
+
233
238
  private get runtimeApi(): OpenClawPluginApi {
234
239
  return this.api;
235
240
  }
@@ -309,6 +314,12 @@ class BncrBridgeRuntime {
309
314
  getSceneRegistry(): Map<string, BncrSceneRecord> {
310
315
  return this.sceneRegistry;
311
316
  }
317
+ resolveOutboundAgentId(route: BncrRoute): string {
318
+ return (
319
+ asString(this.sceneRegistry.get(buildRouteSceneKey(route))?.agentId).trim() ||
320
+ this.defaultPublicAgentId()
321
+ );
322
+ }
312
323
  private conversationHistories: BncrConversationHistoryMap = new Map();
313
324
  private outboundReplayCache: BncrOutboundReplayCache = new Map();
314
325
  private routeAliases = new Map<
@@ -3228,6 +3239,7 @@ export function createBncrChannelPlugin(getBridge: () => BncrBridgeRuntime) {
3228
3239
  return createBncrChannelPluginSurfaceGroup({
3229
3240
  channelId: CHANNEL_ID,
3230
3241
  sceneRegistry: getBridge().getSceneRegistry(),
3242
+ getApi: () => getBridge().getApi(),
3231
3243
  getMessageSendBridge: bridgeGroup.getMessageSendBridge,
3232
3244
  getOutboundBridge: bridgeGroup.getOutboundBridge,
3233
3245
  getMessagingBridge: bridgeGroup.getMessagingBridge,
@@ -27,12 +27,11 @@ type BncrOutboundSessionRoute = ChannelOutboundSessionRoute & BncrChannelRouteRe
27
27
  type ResolveBncrOutboundSessionRouteParams = {
28
28
  cfg: BncrChannelConfigRoot;
29
29
  channel: string;
30
- agentId: string;
31
30
  accountId?: string;
32
31
  target: string;
33
32
  resolvedTarget?: { to?: string } | null;
34
33
  threadId?: string | number | null;
35
- canonicalAgentId: string;
34
+ resolveAgentId: (route: BncrRoute) => string;
36
35
  resolveRouteBySession?: (raw: string, accountId: string) => BncrRoute | null;
37
36
  };
38
37
 
@@ -76,15 +75,14 @@ export function resolveBncrOutboundSessionRoute(params: ResolveBncrOutboundSessi
76
75
 
77
76
  if (!route) return null;
78
77
 
79
- const canonicalAgentId =
80
- asString(params.canonicalAgentId).trim() || asString(params.agentId).trim() || 'main';
78
+ const agentId = asString(params.resolveAgentId(route)).trim() || 'public';
81
79
  const peerId = routeScopeToHex(route);
82
- const sessionKey = buildCanonicalBncrSessionKey(route, canonicalAgentId);
80
+ const sessionKey = buildCanonicalBncrSessionKey(route, agentId);
83
81
  const displayTo = formatDisplayScope(route);
84
82
 
85
83
  const built = buildOpenClawChannelOutboundSessionRoute({
86
84
  cfg: params.cfg,
87
- agentId: canonicalAgentId,
85
+ agentId,
88
86
  channel: params.channel,
89
87
  accountId: params.accountId,
90
88
  peer: {
@@ -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;
@@ -34,6 +34,7 @@ export type BncrChannelPluginBridge = {
34
34
  channelId: string;
35
35
  peer: { kind: 'direct'; id: string };
36
36
  }) => string;
37
+ resolveOutboundAgentId: (route: BncrRoute) => string;
37
38
  resolveRouteBySession: (raw: string, accountId: string) => BncrRoute | null;
38
39
  getChannelSummary: (defaultAccountId: string) => Record<string, unknown>;
39
40
  getAccountRuntimeSnapshot: (accountId: string) => BncrStatusRuntimeSnapshot;
@@ -90,14 +91,7 @@ export function createBncrChannelPluginBridgeGroup(runtime: {
90
91
  const getMessagingBridge = () => {
91
92
  const bridge = runtime.getBridge();
92
93
  return {
93
- canonicalAgentId: undefined,
94
- ensureCanonicalAgentId: (params: { cfg: BncrChannelConfigRoot; accountId: string }) =>
95
- bridge.ensureCanonicalAgentId({
96
- cfg: params.cfg,
97
- accountId: params.accountId,
98
- channelId: runtime.channelId,
99
- peer: { kind: 'direct', id: params.accountId },
100
- }),
94
+ resolveOutboundAgentId: (route: BncrRoute) => bridge.resolveOutboundAgentId(route),
101
95
  resolveRouteBySession: (raw: string, accountId: string) =>
102
96
  bridge.resolveRouteBySession(raw, accountId),
103
97
  };
@@ -3,13 +3,19 @@ import { Type } from 'typebox';
3
3
  import { listAccountIds, normalizeAccountId, resolveAccount } from '../core/accounts.ts';
4
4
  import { BncrConfigSchema } from '../core/config-schema.ts';
5
5
  import { resolveBncrChannelPolicy } from '../core/policy.ts';
6
+ import type { BncrRoute } from '../core/types.ts';
6
7
  import type { ReplyPayloadInput } from '../messaging/outbound/reply-enqueue.ts';
7
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';
8
15
  import type { OpenClawChannelToolSend, openClawJsonResult } from '../openclaw/sdk-helpers.ts';
9
16
  import { readOpenClawStringParam } from '../openclaw/sdk-helpers.ts';
10
17
  import type { BncrBridgeCallBridge } from './bridge-call.ts';
11
18
  import type {
12
- BncrChannelConfigRoot,
13
19
  BncrChannelSendContext,
14
20
  BncrMessageToolSchemaContribution,
15
21
  BncrSceneRecord,
@@ -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>,
@@ -81,8 +88,7 @@ export function createBncrChannelPluginSurfaceGroup(runtime: {
81
88
  };
82
89
  getOutboundBridge: () => BncrOutboundBridge;
83
90
  getMessagingBridge: () => {
84
- canonicalAgentId: undefined;
85
- ensureCanonicalAgentId: (params: { cfg: BncrChannelConfigRoot; accountId: string }) => string;
91
+ resolveOutboundAgentId: (route: BncrRoute) => string;
86
92
  resolveRouteBySession: (raw: string, accountId: string) => BncrVerifiedTarget['route'] | null;
87
93
  };
88
94
  getStatusBridge: () => {
@@ -223,20 +229,49 @@ export function createBncrChannelPluginSurfaceGroup(runtime: {
223
229
  }
224
230
 
225
231
  const to = readOpenClawStringParam(paramsObj, 'to', { required: true });
226
- const toolMediaUrl =
232
+ const rawBuffer = typeof paramsObj.buffer === 'string' ? paramsObj.buffer : '';
233
+ const rawMediaUrl =
227
234
  (typeof paramsObj.mediaUrl === 'string' ? paramsObj.mediaUrl : '') ||
228
235
  (typeof paramsObj.media === 'string' ? paramsObj.media : '') ||
229
236
  (typeof paramsObj.path === 'string' ? paramsObj.path : '') ||
230
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
+ }
231
270
  const rawExtra = paramsObj.extra;
232
271
  const extra =
233
272
  rawExtra && typeof rawExtra === 'object' && !Array.isArray(rawExtra)
234
273
  ? (rawExtra as Record<string, unknown>)
235
274
  : undefined;
236
- const rawMediaUrls = paramsObj.mediaUrls;
237
- const mediaUrls = Array.isArray(rawMediaUrls)
238
- ? rawMediaUrls.filter((u): u is string => typeof u === 'string')
239
- : undefined;
240
275
 
241
276
  const toolMessage = readOpenClawStringParam(paramsObj, 'message', { allowEmpty: true }) ?? '';
242
277
  const toolCaption = readOpenClawStringParam(paramsObj, 'caption', { allowEmpty: true }) ?? '';
@@ -15,8 +15,7 @@ function formatBncrHumanDisplay(route: BncrRoute): string {
15
15
  }
16
16
 
17
17
  type BncrMessagingRuntimeBridge = {
18
- canonicalAgentId?: string;
19
- ensureCanonicalAgentId: (params: { cfg: BncrChannelConfigRoot; accountId: string }) => string;
18
+ resolveOutboundAgentId: (route: BncrRoute) => string;
20
19
  resolveRouteBySession: (raw: string, accountId: string) => BncrRoute | null;
21
20
  };
22
21
 
@@ -44,6 +43,7 @@ type BncrMessagingSessionTargetArgs = {
44
43
  };
45
44
 
46
45
  type BncrMessagingOutboundSessionRouteArgs = {
46
+ /** Host-reported owner. BNCR resolves ownership from its persisted scene registry instead. */
47
47
  agentId: string;
48
48
  target: string;
49
49
  resolvedTarget?: { to?: string } | null;
@@ -99,21 +99,9 @@ function resolveMessagingAccountId(accountId: unknown) {
99
99
  return normalizeAccountId(asSanitizedString(accountId || BNCR_DEFAULT_ACCOUNT_ID));
100
100
  }
101
101
 
102
- function resolveMessagingCanonicalAgentId(
103
- runtimeBridge: BncrMessagingRuntimeBridge,
104
- cfg: BncrChannelConfigRoot,
105
- accountId: string,
106
- ) {
107
- return runtimeBridge.canonicalAgentId || runtimeBridge.ensureCanonicalAgentId({ cfg, accountId });
108
- }
109
-
110
- export function createBncrMessagingExplicitTargetParser(
111
- getBridge: () => BncrMessagingRuntimeBridge,
112
- ) {
102
+ export function createBncrMessagingExplicitTargetParser() {
113
103
  return ({ raw }: BncrMessagingExplicitTargetArgs) => {
114
- const runtimeBridge = getBridge();
115
- const canonicalAgentId = runtimeBridge.canonicalAgentId || 'main';
116
- const parsed = parseExplicitTarget(asSanitizedString(raw).trim(), { canonicalAgentId });
104
+ const parsed = parseExplicitTarget(asSanitizedString(raw).trim());
117
105
  if (!parsed) return null;
118
106
  const chatType: ChatType = parsed.route?.groupId ? 'group' : 'direct';
119
107
  return {
@@ -125,16 +113,12 @@ export function createBncrMessagingExplicitTargetParser(
125
113
  };
126
114
  }
127
115
 
128
- export function createBncrMessagingSessionTargetResolver(
129
- getBridge: () => BncrMessagingRuntimeBridge,
130
- ) {
116
+ export function createBncrMessagingSessionTargetResolver() {
131
117
  return ({ id }: BncrMessagingSessionTargetArgs) => {
132
118
  const raw = asSanitizedString(id).trim();
133
119
  if (!raw) return undefined;
134
- const runtimeBridge = getBridge();
135
- const canonicalAgentId = runtimeBridge.canonicalAgentId || 'main';
136
120
 
137
- const parsed = parseExplicitTarget(raw, { canonicalAgentId });
121
+ const parsed = parseExplicitTarget(raw);
138
122
  if (!parsed) {
139
123
  return raw || undefined;
140
124
  }
@@ -146,17 +130,10 @@ export function createBncrMessagingOutboundSessionRouteResolver(
146
130
  getBridge: () => BncrMessagingRuntimeBridge,
147
131
  ) {
148
132
  return (params: BncrMessagingOutboundSessionRouteArgs) => {
149
- const accountId = resolveMessagingAccountId(params?.accountId);
150
133
  const runtimeBridge = getBridge();
151
- const canonicalAgentId = resolveMessagingCanonicalAgentId(
152
- runtimeBridge,
153
- params?.cfg,
154
- accountId,
155
- );
156
134
  return resolveBncrOutboundSessionRoute({
157
135
  channel: 'bncr',
158
136
  cfg: params.cfg,
159
- agentId: params.agentId,
160
137
  accountId: params.accountId ?? undefined,
161
138
  target: params.target,
162
139
  resolvedTarget: params.resolvedTarget,
@@ -164,7 +141,7 @@ export function createBncrMessagingOutboundSessionRouteResolver(
164
141
  params.threadId === null || params.threadId === undefined
165
142
  ? undefined
166
143
  : asSanitizedString(params.threadId),
167
- canonicalAgentId,
144
+ resolveAgentId: (route) => runtimeBridge.resolveOutboundAgentId(route),
168
145
  resolveRouteBySession: (raw: string, acc: string) =>
169
146
  runtimeBridge.resolveRouteBySession(raw, acc),
170
147
  });
@@ -175,9 +152,9 @@ export function createBncrMessagingSurface(getBridge: () => BncrMessagingRuntime
175
152
  return {
176
153
  // 接收任意标签输入;不在 normalize 阶段做格式门槛,统一下沉到发送前验证。
177
154
  normalizeTarget: normalizeBncrMessagingTarget,
178
- parseExplicitTarget: createBncrMessagingExplicitTargetParser(getBridge),
155
+ parseExplicitTarget: createBncrMessagingExplicitTargetParser(),
179
156
  formatTargetDisplay: formatBncrMessagingTargetDisplay,
180
- resolveSessionTarget: createBncrMessagingSessionTargetResolver(getBridge),
157
+ resolveSessionTarget: createBncrMessagingSessionTargetResolver(),
181
158
  resolveOutboundSessionRoute: createBncrMessagingOutboundSessionRouteResolver(getBridge),
182
159
  targetResolver: createBncrMessagingTargetResolver(getBridge),
183
160
  };
@@ -1,3 +1,5 @@
1
+ import type { BncrRoute } from '../core/types.ts';
2
+ import { asString } from '../core/value-sanitize.ts';
1
3
  import type {
2
4
  BncrGroupReplyMode,
3
5
  BncrSceneKind,
@@ -30,10 +32,19 @@ function defaultGroupReplyMode(kind: BncrSceneKind): BncrGroupReplyMode | undefi
30
32
  return kind === 'group' ? 'admin' : undefined;
31
33
  }
32
34
 
35
+ export function buildRouteSceneKey(route: BncrRoute): string {
36
+ const platform = asString(route.platform).trim();
37
+ const groupId = asString(route.groupId).trim() || '0';
38
+ const userId = asString(route.userId).trim() || '0';
39
+ return groupId !== '0' ? `${platform}:${groupId}` : `${platform}:${userId}`;
40
+ }
41
+
33
42
  export function buildSceneKey(parsed: ParsedInbound): string {
34
- return parsed.peer.kind === 'group'
35
- ? `${parsed.platform}:${parsed.groupId}`
36
- : `${parsed.platform}:${parsed.userId}`;
43
+ return buildRouteSceneKey({
44
+ platform: parsed.platform,
45
+ groupId: parsed.groupId || '0',
46
+ userId: parsed.userId || '0',
47
+ });
37
48
  }
38
49
 
39
50
  function buildSceneRecord(args: {