@xmoxmo/bncr 0.6.4 → 0.6.6

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.
@@ -1,13 +1,17 @@
1
+ import { hasControlCommand } from 'openclaw/plugin-sdk/command-auth';
1
2
  import { emitBncrLogLine } from '../../core/logging.ts';
2
3
  import { resolveBncrChannelPolicy } from '../../core/policy.ts';
3
4
  import { formatDisplayScope } from '../../core/targets.ts';
4
5
  import {
6
+ readBncrSessionEntry,
5
7
  recordBncrInboundSession,
6
8
  resolveBncrInboundSessionStorePath,
7
9
  resolveBncrPinnedMainDmOwnerFromAllowlist,
8
10
  } from '../../openclaw/inbound-session-runtime.ts';
9
11
  import { dispatchOpenClawReplyWithBufferedBlockDispatcher } from '../../openclaw/reply-runtime.ts';
10
12
  import { resolveOpenClawAgentRoute } from '../../openclaw/routing-runtime.ts';
13
+ import { performBncrGatewaySessionReset } from '../../openclaw/session-reset-runtime.ts';
14
+ import { mergeBncrOwnerAllowFromIntoConfig } from './command-owner.ts';
11
15
  import type {
12
16
  BncrEnqueueFromReply,
13
17
  BncrInboundApi,
@@ -18,8 +22,13 @@ import type {
18
22
  import { assertResolvedAgentRoute, type ParsedInbound } from './dispatch-prep.ts';
19
23
  import { buildBncrInboundRecordUpdateLastRoute } from './last-route.ts';
20
24
  import {
25
+ BNCR_OPENCLAW_NATIVE_COMMAND,
26
+ BNCR_SELF_SERVICE_COMMANDS,
27
+ isBncrStopCommandText,
28
+ isBncrWhitelistBareCommandText,
21
29
  parseBncrNativeCommand,
22
30
  parseBncrUnsupportedDirectCommand,
31
+ resolveBncrNativeCommandParseOptions,
23
32
  resolveBncrNativeHelpCommand,
24
33
  resolveBncrNativeSessionResetCommand,
25
34
  resolveBncrNativeStatusCommand,
@@ -108,14 +117,90 @@ export async function handleBncrNativeCommand(params: {
108
117
  enqueueFromReply,
109
118
  } = params;
110
119
  const { accountId, route, peer, clientId, extracted, msgId } = parsed;
111
- const command = parseBncrNativeCommand(extracted.text, {
112
- allowBareWhoami: parsed.isAdmin !== true,
113
- allowBareStatus: parsed.isAdmin !== true && parsed.peer.kind === 'direct',
114
- allowBareSessionReset: parsed.isAdmin !== true && parsed.peer.kind === 'direct',
115
- });
116
- if (!command && parsed.peer.kind === 'direct') {
117
- const unsupportedDirectCommand = parseBncrUnsupportedDirectCommand(extracted.text);
118
- if (unsupportedDirectCommand) {
120
+
121
+ // Stop always goes through the unified stop fast path in dispatch.ts.
122
+ // It is not a bncr native command and must never be processed here.
123
+ // Only exact `/stop` is accepted; any other `/stop*` form is rejected.
124
+ const rawStopText = String(extracted.text || '')
125
+ .trim()
126
+ .toLowerCase();
127
+ if (rawStopText.startsWith('/stop')) {
128
+ if (isBncrStopCommandText(extracted.text)) {
129
+ return { handled: false };
130
+ }
131
+ // Non-exact `/stop` forms (extra args, @bot suffix, or other extension)
132
+ // are rejected immediately so they never reach the agent.
133
+ const rejectedRoute = buildBncrNativeCommandResolvedRoute({
134
+ api,
135
+ cfg,
136
+ channelId,
137
+ accountId,
138
+ peer,
139
+ resolvedAgentId,
140
+ });
141
+ const { baseSessionKey, sessionKey } = buildBncrNativeCommandSessionState({
142
+ parsed,
143
+ sessionAgentId: rejectedRoute.agentId || canonicalAgentId,
144
+ resolvedRoute: rejectedRoute,
145
+ });
146
+ logBncrNativeCommandSummary(
147
+ buildBncrNativeCommandSummary({
148
+ kind: 'stop',
149
+ command: rawStopText,
150
+ accountId,
151
+ to: formatDisplayScope(route),
152
+ msgId: msgId || null,
153
+ result: 'rejected',
154
+ }),
155
+ );
156
+ await enqueueFromReply({
157
+ accountId,
158
+ sessionKey: baseSessionKey,
159
+ route,
160
+ payload: {
161
+ text: 'Only exact /stop is supported. Send /stop to stop.',
162
+ replyToId: msgId || undefined,
163
+ },
164
+ });
165
+ return { handled: true, command: 'stop', sessionKey };
166
+ }
167
+
168
+ const whitelistBareCommand =
169
+ !parsed.isAdmin && parsed.peer.kind === 'direct'
170
+ ? isBncrWhitelistBareCommandText(extracted.text)
171
+ : null;
172
+ let dispatchToOpenClaw =
173
+ whitelistBareCommand === 'whoami' ||
174
+ whitelistBareCommand === 'status' ||
175
+ whitelistBareCommand === 'model' ||
176
+ whitelistBareCommand === 'verbose';
177
+ const effectiveIsAdmin = whitelistBareCommand ? true : parsed.isAdmin;
178
+ let command: ReturnType<typeof parseBncrNativeCommand>;
179
+ if (whitelistBareCommand) {
180
+ // Private non-admin whitelist commands are temporarily elevated to admin
181
+ // for the local session-reset handlers or handed to the OpenClaw native
182
+ // parser. They never fall back to the agent so the agent cannot observe
183
+ // a conflicting identity.
184
+ const raw = String(extracted.text || '').trim();
185
+ command = { command: whitelistBareCommand, raw, body: raw, argsText: '' };
186
+ const argsTextStart = raw.indexOf(' ');
187
+ if (argsTextStart !== -1) {
188
+ command.argsText = raw.slice(argsTextStart + 1).trim();
189
+ }
190
+ } else {
191
+ command = parseBncrNativeCommand(
192
+ extracted.text,
193
+ resolveBncrNativeCommandParseOptions({
194
+ isAdmin: parsed.isAdmin,
195
+ peerKind: parsed.peer.kind as 'direct' | 'group',
196
+ }),
197
+ );
198
+ }
199
+ if (!command) {
200
+ const unsupportedCommand = parseBncrUnsupportedDirectCommand(extracted.text);
201
+ // Unknown /bncr subcommands are rejected by bncr without agent fallback,
202
+ // for both admin and non-admin callers and in private or group chat.
203
+ if (unsupportedCommand?.command.startsWith('bncr ')) {
119
204
  const rejectedRoute = buildBncrNativeCommandResolvedRoute({
120
205
  api,
121
206
  cfg,
@@ -133,7 +218,7 @@ export async function handleBncrNativeCommand(params: {
133
218
  logBncrNativeCommandSummary(
134
219
  buildBncrNativeCommandSummary({
135
220
  kind: 'unsupported',
136
- command: unsupportedDirectCommand.command,
221
+ command: unsupportedCommand.command,
137
222
  accountId,
138
223
  to: displayTo,
139
224
  msgId: msgId || null,
@@ -145,20 +230,43 @@ export async function handleBncrNativeCommand(params: {
145
230
  sessionKey: baseSessionKey,
146
231
  route,
147
232
  payload: {
148
- text: `Unsupported private-chat command: /${unsupportedDirectCommand.command}`,
233
+ text: `Unsupported command: /${unsupportedCommand.command}`,
149
234
  replyToId: msgId || undefined,
150
235
  },
151
236
  });
152
- return { handled: true, command: unsupportedDirectCommand.command, sessionKey };
237
+ return { handled: true, command: unsupportedCommand.command, sessionKey };
238
+ }
239
+ // Non-bncr bare commands fall through to the OpenClaw parser. Admin
240
+ // callers may fall back to the agent; non-admin callers keep their
241
+ // original identity when they do.
242
+
243
+ // Admin bare OpenClaw native commands are routed to the OpenClaw parser
244
+ // and handled there, preventing a second agent turn from producing a
245
+ // duplicate reply. This mirrors the non-admin whitelist elevation path.
246
+ if (
247
+ !command &&
248
+ parsed.isAdmin &&
249
+ hasControlCommand(extracted.text, cfg as Parameters<typeof hasControlCommand>[1])
250
+ ) {
251
+ const raw = String(extracted.text || '').trim();
252
+ command = { command: BNCR_OPENCLAW_NATIVE_COMMAND, raw, body: raw, argsText: '' };
253
+ const argsTextStart = raw.indexOf(' ');
254
+ if (argsTextStart !== -1) {
255
+ command.argsText = raw.slice(argsTextStart + 1).trim();
256
+ }
257
+ dispatchToOpenClaw = true;
153
258
  }
154
259
  }
155
260
  if (!command) return { handled: false };
156
261
  const nativeCommandDebugEnabled = resolveNativeCommandDebugEnabled({ cfg, channelId });
157
262
 
263
+ const displayCommand =
264
+ command.command === BNCR_OPENCLAW_NATIVE_COMMAND ? command.raw : command.command;
265
+
158
266
  logBncrNativeCommandEvent(
159
267
  'detected',
160
268
  {
161
- command: command.command,
269
+ command: displayCommand,
162
270
  accountId,
163
271
  to: formatDisplayScope(route),
164
272
  msgId: msgId || null,
@@ -198,6 +306,9 @@ export async function handleBncrNativeCommand(params: {
198
306
  agentId: resolvedRoute.agentId,
199
307
  });
200
308
 
309
+ const dispatchOwnerAllowFrom =
310
+ dispatchToOpenClaw && senderIdForContext ? [senderIdForContext] : undefined;
311
+
201
312
  const ctxPayload = await Promise.resolve(
202
313
  createNativeCommandTurnContext({
203
314
  api,
@@ -207,6 +318,7 @@ export async function handleBncrNativeCommand(params: {
207
318
  peer,
208
319
  resolvedRoute,
209
320
  sessionKey,
321
+ ownerAllowFrom: dispatchOwnerAllowFrom,
210
322
  displayTo,
211
323
  originatingTo,
212
324
  senderIdForContext,
@@ -223,9 +335,16 @@ export async function handleBncrNativeCommand(params: {
223
335
  senderId: senderIdForContext,
224
336
  });
225
337
 
226
- const nativeVerbose = resolveBncrNativeVerboseCommand(command);
338
+ const currentVerboseLevel = storePath
339
+ ? ((await readBncrSessionEntry({ storePath, sessionKey }))?.verboseLevel as
340
+ | 'on'
341
+ | 'off'
342
+ | 'full'
343
+ | undefined)
344
+ : undefined;
345
+ const nativeVerbose = resolveBncrNativeVerboseCommand(command, currentVerboseLevel);
227
346
  const nativeHelp = resolveBncrNativeHelpCommand(command, {
228
- isAdmin: parsed.isAdmin,
347
+ isAdmin: effectiveIsAdmin,
229
348
  peerKind: parsed.peer.kind as 'direct' | 'group',
230
349
  });
231
350
  const nativeWhoami = resolveBncrNativeWhoamiCommand({
@@ -236,7 +355,7 @@ export async function handleBncrNativeCommand(params: {
236
355
  userId: parsed.userId,
237
356
  userName: parsed.userName,
238
357
  isGroup: parsed.isGroup,
239
- isAdmin: parsed.isAdmin,
358
+ isAdmin: effectiveIsAdmin,
240
359
  });
241
360
  const nativeStatus = resolveBncrNativeStatusCommand({
242
361
  command,
@@ -251,107 +370,16 @@ export async function handleBncrNativeCommand(params: {
251
370
  command,
252
371
  peerKind: parsed.peer.kind as 'direct' | 'group',
253
372
  });
254
- if (nativeHelp) {
255
- logBncrNativeCommandSummary(
256
- buildBncrNativeCommandSummary({
257
- kind: 'help',
258
- command: command.command,
259
- accountId,
260
- to: displayTo,
261
- msgId: msgId || null,
262
- result: 'handled',
263
- }),
264
- );
265
- await recordAndPatchBncrInboundSessionEntry({
266
- storePath,
267
- sessionKey,
268
- ctx: ctxPayload,
269
- patch: sessionIdentityPatch,
270
- });
271
- rememberSessionRoute(baseSessionKey, accountId, route);
272
- await enqueueFromReply({
273
- accountId,
274
- sessionKey,
275
- route,
276
- payload: {
277
- text: nativeHelp.text,
278
- replyToId: msgId || undefined,
279
- },
280
- });
281
- return { handled: true, command: command.command, sessionKey };
282
- }
283
-
284
- if (nativeWhoami) {
285
- logBncrNativeCommandSummary(
286
- buildBncrNativeCommandSummary({
287
- kind: 'whoami',
288
- command: command.command,
289
- accountId,
290
- to: displayTo,
291
- msgId: msgId || null,
292
- result: 'handled',
293
- }),
294
- );
295
- await recordAndPatchBncrInboundSessionEntry({
296
- storePath,
297
- sessionKey,
298
- ctx: ctxPayload,
299
- patch: sessionIdentityPatch,
300
- });
301
- rememberSessionRoute(baseSessionKey, accountId, route);
302
- await enqueueFromReply({
303
- accountId,
304
- sessionKey,
305
- route,
306
- payload: {
307
- text: nativeWhoami.text,
308
- replyToId: msgId || undefined,
309
- },
310
- });
311
- return { handled: true, command: command.command, sessionKey };
312
- }
313
-
314
- if (nativeStatus) {
315
- logBncrNativeCommandSummary(
316
- buildBncrNativeCommandSummary({
317
- kind: 'status',
318
- command: command.command,
319
- accountId,
320
- to: displayTo,
321
- msgId: msgId || null,
322
- result: 'handled',
323
- }),
324
- );
325
- await recordAndPatchBncrInboundSessionEntry({
326
- storePath,
327
- sessionKey,
328
- ctx: ctxPayload,
329
- patch: sessionIdentityPatch,
330
- });
331
- rememberSessionRoute(baseSessionKey, accountId, route);
332
- await enqueueFromReply({
333
- accountId,
334
- sessionKey,
335
- route,
336
- payload: {
337
- text: nativeStatus.text,
338
- replyToId: msgId || undefined,
339
- },
340
- });
341
- return { handled: true, command: command.command, sessionKey };
342
- }
343
-
344
- if (nativeSessionReset) {
345
- const allowLocalSessionReset = parsed.peer.kind === 'direct' || parsed.isAdmin;
346
- if (!allowLocalSessionReset) {
373
+ if (!dispatchToOpenClaw) {
374
+ if (nativeHelp) {
347
375
  logBncrNativeCommandSummary(
348
376
  buildBncrNativeCommandSummary({
349
- kind: nativeSessionReset.reason,
377
+ kind: 'help',
350
378
  command: command.command,
351
379
  accountId,
352
380
  to: displayTo,
353
381
  msgId: msgId || null,
354
- result: 'rejected',
382
+ result: 'handled',
355
383
  }),
356
384
  );
357
385
  await recordAndPatchBncrInboundSessionEntry({
@@ -361,63 +389,57 @@ export async function handleBncrNativeCommand(params: {
361
389
  patch: sessionIdentityPatch,
362
390
  });
363
391
  rememberSessionRoute(baseSessionKey, accountId, route);
392
+ await enqueueFromReply({
393
+ accountId,
394
+ sessionKey,
395
+ route,
396
+ payload: {
397
+ text: nativeHelp.text,
398
+ replyToId: msgId || undefined,
399
+ },
400
+ });
364
401
  return { handled: true, command: command.command, sessionKey };
365
402
  }
366
403
 
367
- const requestedAgentId = resolvedRoute.agentId || canonicalAgentId;
368
- const requestApi = api as BncrInboundApi & {
369
- request?: (method: string, params: Record<string, unknown>) => Promise<unknown>;
370
- };
371
- if (typeof requestApi.request !== 'function') {
372
- throw new Error('OpenClaw plugin api.request is unavailable for sessions.reset');
373
- }
374
- await recordAndPatchBncrInboundSessionEntry({
375
- storePath,
376
- sessionKey,
377
- ctx: ctxPayload,
378
- patch: sessionIdentityPatch,
379
- });
380
- const resetResult = (await requestApi.request('sessions.reset', {
381
- key: baseSessionKey,
382
- reason: nativeSessionReset.reason,
383
- ...(requestedAgentId ? { agentId: requestedAgentId } : {}),
384
- })) as { ok?: boolean } | undefined;
385
- if (resetResult?.ok === false) {
386
- throw new Error(`sessions.reset failed for ${baseSessionKey}`);
387
- }
388
- logBncrNativeCommandSummary(
389
- buildBncrNativeCommandSummary({
390
- kind: nativeSessionReset.reason,
391
- command: command.command,
404
+ if (nativeWhoami) {
405
+ logBncrNativeCommandSummary(
406
+ buildBncrNativeCommandSummary({
407
+ kind: 'whoami',
408
+ command: command.command,
409
+ accountId,
410
+ to: displayTo,
411
+ msgId: msgId || null,
412
+ result: 'handled',
413
+ }),
414
+ );
415
+ await recordAndPatchBncrInboundSessionEntry({
416
+ storePath,
417
+ sessionKey,
418
+ ctx: ctxPayload,
419
+ patch: sessionIdentityPatch,
420
+ });
421
+ rememberSessionRoute(baseSessionKey, accountId, route);
422
+ await enqueueFromReply({
392
423
  accountId,
393
- to: displayTo,
394
- msgId: msgId || null,
395
- result: 'handled',
396
- }),
397
- );
398
- rememberSessionRoute(baseSessionKey, accountId, route);
399
- await enqueueFromReply({
400
- accountId,
401
- sessionKey: baseSessionKey,
402
- route,
403
- payload: {
404
- text: nativeSessionReset.text,
405
- replyToId: msgId || undefined,
406
- },
407
- });
408
- return { handled: true, command: command.command, sessionKey: baseSessionKey };
409
- }
424
+ sessionKey,
425
+ route,
426
+ payload: {
427
+ text: nativeWhoami.text,
428
+ replyToId: msgId || undefined,
429
+ },
430
+ });
431
+ return { handled: true, command: command.command, sessionKey };
432
+ }
410
433
 
411
- if (nativeVerbose) {
412
- if (!parsed.isAdmin) {
434
+ if (nativeStatus) {
413
435
  logBncrNativeCommandSummary(
414
436
  buildBncrNativeCommandSummary({
415
- kind: 'verbose',
437
+ kind: 'status',
416
438
  command: command.command,
417
439
  accountId,
418
440
  to: displayTo,
419
441
  msgId: msgId || null,
420
- result: 'rejected',
442
+ result: 'handled',
421
443
  }),
422
444
  );
423
445
  await recordAndPatchBncrInboundSessionEntry({
@@ -427,96 +449,258 @@ export async function handleBncrNativeCommand(params: {
427
449
  patch: sessionIdentityPatch,
428
450
  });
429
451
  rememberSessionRoute(baseSessionKey, accountId, route);
430
- if (parsed.peer.kind !== 'group') {
452
+ await enqueueFromReply({
453
+ accountId,
454
+ sessionKey,
455
+ route,
456
+ payload: {
457
+ text: nativeStatus.text,
458
+ replyToId: msgId || undefined,
459
+ },
460
+ });
461
+ return { handled: true, command: command.command, sessionKey };
462
+ }
463
+
464
+ if (nativeSessionReset) {
465
+ const allowLocalSessionReset = parsed.peer.kind === 'direct' || effectiveIsAdmin;
466
+ if (!allowLocalSessionReset) {
467
+ logBncrNativeCommandSummary(
468
+ buildBncrNativeCommandSummary({
469
+ kind: nativeSessionReset.reason,
470
+ command: command.command,
471
+ accountId,
472
+ to: displayTo,
473
+ msgId: msgId || null,
474
+ result: 'rejected',
475
+ }),
476
+ );
477
+ await recordAndPatchBncrInboundSessionEntry({
478
+ storePath,
479
+ sessionKey,
480
+ ctx: ctxPayload,
481
+ patch: sessionIdentityPatch,
482
+ });
483
+ rememberSessionRoute(baseSessionKey, accountId, route);
484
+ return { handled: true, command: command.command, sessionKey };
485
+ }
486
+
487
+ const requestedAgentId = resolvedRoute.agentId || canonicalAgentId;
488
+ await recordAndPatchBncrInboundSessionEntry({
489
+ storePath,
490
+ sessionKey,
491
+ ctx: ctxPayload,
492
+ patch: sessionIdentityPatch,
493
+ });
494
+ let resetResult: { ok?: boolean } | undefined;
495
+ try {
496
+ resetResult = await performBncrGatewaySessionReset({
497
+ key: baseSessionKey,
498
+ reason: nativeSessionReset.reason,
499
+ ...(requestedAgentId ? { agentId: requestedAgentId } : {}),
500
+ commandSource: 'bncr:native-command',
501
+ });
502
+ } catch (resetError) {
503
+ emitBncrLogLine(
504
+ 'error',
505
+ `[bncr] native-command sessions.reset failed|key=${baseSessionKey}|err=${resetError instanceof Error ? resetError.message : String(resetError)}`,
506
+ );
431
507
  await enqueueFromReply({
432
508
  accountId,
433
- sessionKey,
509
+ sessionKey: baseSessionKey,
434
510
  route,
435
511
  payload: {
436
- text: 'Admin permission required.',
512
+ text: `Session reset failed: ${resetError instanceof Error ? resetError.message : String(resetError)}`,
437
513
  replyToId: msgId || undefined,
438
514
  },
439
515
  });
516
+ return { handled: true, command: command.command, sessionKey: baseSessionKey };
440
517
  }
441
- return { handled: true, command: command.command, sessionKey };
518
+ if (resetResult?.ok === false) {
519
+ await enqueueFromReply({
520
+ accountId,
521
+ sessionKey: baseSessionKey,
522
+ route,
523
+ payload: {
524
+ text: 'Session reset was rejected by the gateway.',
525
+ replyToId: msgId || undefined,
526
+ },
527
+ });
528
+ return { handled: true, command: command.command, sessionKey: baseSessionKey };
529
+ }
530
+ logBncrNativeCommandSummary(
531
+ buildBncrNativeCommandSummary({
532
+ kind: nativeSessionReset.reason,
533
+ command: command.command,
534
+ accountId,
535
+ to: displayTo,
536
+ msgId: msgId || null,
537
+ result: 'handled',
538
+ }),
539
+ );
540
+ rememberSessionRoute(baseSessionKey, accountId, route);
541
+ await enqueueFromReply({
542
+ accountId,
543
+ sessionKey: baseSessionKey,
544
+ route,
545
+ payload: {
546
+ text: nativeSessionReset.text,
547
+ replyToId: msgId || undefined,
548
+ },
549
+ });
550
+ return { handled: true, command: command.command, sessionKey: baseSessionKey };
442
551
  }
443
552
 
444
- logBncrNativeCommandSummary(
445
- buildBncrNativeCommandSummary({
446
- kind: 'verbose',
447
- command: command.command,
448
- accountId,
449
- to: displayTo,
450
- msgId: msgId || null,
451
- result: 'handled',
452
- }),
453
- );
454
- logBncrNativeCommandEvent(
455
- 'handled-verbose',
456
- {
457
- command: command.command,
458
- accountId,
459
- sessionKey,
460
- to: displayTo,
461
- msgId: msgId || null,
462
- fallbackToAgent: false,
463
- },
464
- { debugOnly: true, debugEnabled: nativeCommandDebugEnabled },
465
- );
466
- await recordAndPatchBncrInboundSessionEntry({
467
- storePath,
468
- sessionKey,
469
- ctx: ctxPayload,
470
- patch: {
471
- ...sessionIdentityPatch,
472
- ...(nativeVerbose.verboseLevel ? { verboseLevel: nativeVerbose.verboseLevel } : {}),
473
- },
474
- });
475
- rememberSessionRoute(baseSessionKey, accountId, route);
476
- await enqueueFromReply({
477
- accountId,
478
- sessionKey,
479
- route,
480
- payload: {
481
- text: nativeVerbose.text,
482
- replyToId: msgId || undefined,
483
- },
484
- });
485
- return { handled: true, command: command.command, sessionKey };
486
- }
553
+ if (nativeVerbose) {
554
+ if (!effectiveIsAdmin && parsed.peer.kind !== 'direct') {
555
+ logBncrNativeCommandSummary(
556
+ buildBncrNativeCommandSummary({
557
+ kind: 'verbose',
558
+ command: command.command,
559
+ accountId,
560
+ to: displayTo,
561
+ msgId: msgId || null,
562
+ result: 'rejected',
563
+ }),
564
+ );
565
+ await recordAndPatchBncrInboundSessionEntry({
566
+ storePath,
567
+ sessionKey,
568
+ ctx: ctxPayload,
569
+ patch: sessionIdentityPatch,
570
+ });
571
+ rememberSessionRoute(baseSessionKey, accountId, route);
572
+ if (parsed.peer.kind !== 'group') {
573
+ await enqueueFromReply({
574
+ accountId,
575
+ sessionKey,
576
+ route,
577
+ payload: {
578
+ text: 'Admin permission required.',
579
+ replyToId: msgId || undefined,
580
+ },
581
+ });
582
+ }
583
+ return { handled: true, command: command.command, sessionKey };
584
+ }
487
585
 
488
- const sceneAdmin = parseSceneAdminCommand(command);
489
- if (sceneAdmin.matched) {
490
- if (!parsed.isAdmin) {
491
586
  logBncrNativeCommandSummary(
492
587
  buildBncrNativeCommandSummary({
493
- kind: 'scene-admin',
588
+ kind: 'verbose',
494
589
  command: command.command,
495
590
  accountId,
496
591
  to: displayTo,
497
- msgId: msgId ?? null,
498
- result: 'rejected',
592
+ msgId: msgId || null,
593
+ result: 'handled',
499
594
  }),
500
595
  );
596
+ logBncrNativeCommandEvent(
597
+ 'handled-verbose',
598
+ {
599
+ command: command.command,
600
+ accountId,
601
+ sessionKey,
602
+ to: displayTo,
603
+ msgId: msgId || null,
604
+ fallbackToAgent: false,
605
+ },
606
+ { debugOnly: true, debugEnabled: nativeCommandDebugEnabled },
607
+ );
501
608
  await recordAndPatchBncrInboundSessionEntry({
502
609
  storePath,
503
610
  sessionKey,
504
611
  ctx: ctxPayload,
505
- patch: sessionIdentityPatch,
612
+ patch: {
613
+ ...sessionIdentityPatch,
614
+ ...(nativeVerbose.verboseLevel ? { verboseLevel: nativeVerbose.verboseLevel } : {}),
615
+ },
506
616
  });
617
+ rememberSessionRoute(baseSessionKey, accountId, route);
507
618
  await enqueueFromReply({
508
619
  accountId,
509
620
  sessionKey,
510
621
  route,
511
622
  payload: {
512
- text: 'Admin permission required.',
623
+ text: nativeVerbose.text,
513
624
  replyToId: msgId || undefined,
514
625
  },
515
626
  });
516
627
  return { handled: true, command: command.command, sessionKey };
517
628
  }
518
629
 
519
- if (!sceneAdmin.valid) {
630
+ const sceneAdmin = parseSceneAdminCommand(command);
631
+ if (sceneAdmin.matched) {
632
+ const directSelfAdminCommand =
633
+ parsed.peer.kind === 'direct' &&
634
+ new Set(['history-help', 'history-limit', 'history-force', 'download-media']).has(
635
+ command.command,
636
+ );
637
+ if (!effectiveIsAdmin && !directSelfAdminCommand) {
638
+ logBncrNativeCommandSummary(
639
+ buildBncrNativeCommandSummary({
640
+ kind: 'scene-admin',
641
+ command: command.command,
642
+ accountId,
643
+ to: displayTo,
644
+ msgId: msgId ?? null,
645
+ result: 'rejected',
646
+ }),
647
+ );
648
+ await recordAndPatchBncrInboundSessionEntry({
649
+ storePath,
650
+ sessionKey,
651
+ ctx: ctxPayload,
652
+ patch: sessionIdentityPatch,
653
+ });
654
+ await enqueueFromReply({
655
+ accountId,
656
+ sessionKey,
657
+ route,
658
+ payload: {
659
+ text: 'Admin permission required.',
660
+ replyToId: msgId || undefined,
661
+ },
662
+ });
663
+ return { handled: true, command: command.command, sessionKey };
664
+ }
665
+
666
+ if (!sceneAdmin.valid) {
667
+ logBncrNativeCommandSummary(
668
+ buildBncrNativeCommandSummary({
669
+ kind: 'scene-admin',
670
+ command: command.command,
671
+ accountId,
672
+ to: displayTo,
673
+ msgId: msgId ?? null,
674
+ result: 'rejected',
675
+ }),
676
+ );
677
+ await recordAndPatchBncrInboundSessionEntry({
678
+ storePath,
679
+ sessionKey,
680
+ ctx: ctxPayload,
681
+ patch: sessionIdentityPatch,
682
+ });
683
+ await enqueueFromReply({
684
+ accountId,
685
+ sessionKey,
686
+ route,
687
+ payload: {
688
+ text: sceneAdmin.text,
689
+ replyToId: msgId || undefined,
690
+ },
691
+ });
692
+ return { handled: true, command: command.command, sessionKey };
693
+ }
694
+
695
+ const outcome = executeSceneAdminCommand({
696
+ parsed,
697
+ command: sceneAdmin.command,
698
+ sceneRegistry,
699
+ defaultAdminAgentId,
700
+ defaultPublicAgentId,
701
+ now,
702
+ allowNonAdminSelfAdmin: !!directSelfAdminCommand,
703
+ });
520
704
  logBncrNativeCommandSummary(
521
705
  buildBncrNativeCommandSummary({
522
706
  kind: 'scene-admin',
@@ -524,7 +708,7 @@ export async function handleBncrNativeCommand(params: {
524
708
  accountId,
525
709
  to: displayTo,
526
710
  msgId: msgId ?? null,
527
- result: 'rejected',
711
+ result: outcome.ok ? 'handled' : 'rejected',
528
712
  }),
529
713
  );
530
714
  await recordAndPatchBncrInboundSessionEntry({
@@ -538,47 +722,12 @@ export async function handleBncrNativeCommand(params: {
538
722
  sessionKey,
539
723
  route,
540
724
  payload: {
541
- text: sceneAdmin.text,
725
+ text: outcome.text,
542
726
  replyToId: msgId || undefined,
543
727
  },
544
728
  });
545
729
  return { handled: true, command: command.command, sessionKey };
546
730
  }
547
-
548
- const outcome = executeSceneAdminCommand({
549
- parsed,
550
- command: sceneAdmin.command,
551
- sceneRegistry,
552
- defaultAdminAgentId,
553
- defaultPublicAgentId,
554
- now,
555
- });
556
- logBncrNativeCommandSummary(
557
- buildBncrNativeCommandSummary({
558
- kind: 'scene-admin',
559
- command: command.command,
560
- accountId,
561
- to: displayTo,
562
- msgId: msgId ?? null,
563
- result: outcome.ok ? 'handled' : 'rejected',
564
- }),
565
- );
566
- await recordAndPatchBncrInboundSessionEntry({
567
- storePath,
568
- sessionKey,
569
- ctx: ctxPayload,
570
- patch: sessionIdentityPatch,
571
- });
572
- await enqueueFromReply({
573
- accountId,
574
- sessionKey,
575
- route,
576
- payload: {
577
- text: outcome.text,
578
- replyToId: msgId || undefined,
579
- },
580
- });
581
- return { handled: true, command: command.command, sessionKey };
582
731
  }
583
732
 
584
733
  await recordAndPatchBncrInboundSessionEntry({
@@ -588,7 +737,11 @@ export async function handleBncrNativeCommand(params: {
588
737
  patch: sessionIdentityPatch,
589
738
  });
590
739
 
591
- const effectiveReply = buildBncrReplyConfig(cfg);
740
+ const dispatchCfgForElevated =
741
+ dispatchToOpenClaw && senderIdForContext
742
+ ? mergeBncrOwnerAllowFromIntoConfig({ cfg, senderIdForContext })
743
+ : cfg;
744
+ const effectiveReply = buildBncrReplyConfig(dispatchCfgForElevated);
592
745
  const channelPolicy = resolveBncrChannelPolicy(cfg?.channels?.bncr || {});
593
746
  const pinnedMainDmOwner =
594
747
  peer.kind === 'direct'
@@ -691,13 +844,35 @@ export async function handleBncrNativeCommand(params: {
691
844
  });
692
845
 
693
846
  if (!responded) {
847
+ // Self-service commands must never fall back to the agent — doing so
848
+ // would let the agent see a message whose author identity was
849
+ // temporarily elevated, causing role confusion.
850
+ if (BNCR_SELF_SERVICE_COMMANDS.has(command.command)) {
851
+ emitBncrLogLine(
852
+ 'warn',
853
+ `[bncr] self-service command=${displayCommand} produced no payload; refusing agent fallback`,
854
+ );
855
+ await enqueueFromReply({
856
+ accountId,
857
+ sessionKey,
858
+ route,
859
+ payload: {
860
+ text: `Command /${command.command} failed.`,
861
+ replyToId: msgId || undefined,
862
+ },
863
+ });
864
+ return buildNativeCommandHandledResult({
865
+ command: command.command,
866
+ sessionKey,
867
+ });
868
+ }
694
869
  logBncrNativeCommandSummary(
695
- `fallback command=${command.command}|accountId=${accountId}|to=${displayTo}|msgId=${msgId || '-'}|reason=no-payload`,
870
+ `fallback command=${displayCommand}|accountId=${accountId}|to=${displayTo}|msgId=${msgId || '-'}|reason=no-payload`,
696
871
  );
697
872
  logBncrNativeCommandEvent(
698
873
  'no-payload-fallback-to-agent',
699
874
  {
700
- command: command.command,
875
+ command: displayCommand,
701
876
  accountId,
702
877
  sessionKey,
703
878
  to: displayTo,