@xmoxmo/bncr 0.4.7 → 0.4.9

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/README.md CHANGED
@@ -81,6 +81,14 @@ openclaw devices approve --latest
81
81
  - 状态诊断
82
82
  - 文件互传
83
83
 
84
+ 补充口径:
85
+
86
+ - 入站会话按正式 scene / route 语义接入 OpenClaw,区分私聊与群聊会话。
87
+ - 非管理员私聊支持受控的本地命令能力:`/whoami`、`/status`、`/new`、`/reset`,以及对应的 `/bncr ...` 入口。
88
+ - 非管理员私聊中,不在支持范围内的 slash 命令会由 bncr 直接拒绝,不再 fallback 到 agent。
89
+ - 群聊里的管理型 / 控制型命令对非管理员按静默拒绝处理,不给用户可见回复。
90
+ - `/bncr list pending` 与 `/bncr list scenes` 采用按私聊 / 群聊分组的摘要展示格式,便于现场排查 scene 状态。
91
+
84
92
  ---
85
93
 
86
94
  ## 5. 架构定位
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmoxmo/bncr",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,7 +22,10 @@ import type { ParsedInbound } from './dispatch-prep.ts';
22
22
  import { buildBncrInboundRecordUpdateLastRoute } from './last-route.ts';
23
23
  import {
24
24
  parseBncrNativeCommand,
25
+ parseBncrUnsupportedDirectCommand,
25
26
  resolveBncrNativeHelpCommand,
27
+ resolveBncrNativeSessionResetCommand,
28
+ resolveBncrNativeStatusCommand,
26
29
  resolveBncrNativeVerboseCommand,
27
30
  resolveBncrNativeWhoamiCommand,
28
31
  } from './native-command.ts';
@@ -129,7 +132,48 @@ export async function handleBncrNativeCommand(params: {
129
132
  const { accountId, route, peer, clientId, extracted, msgId } = parsed;
130
133
  const command = parseBncrNativeCommand(extracted.text, {
131
134
  allowBareWhoami: parsed.isAdmin !== true,
135
+ allowBareStatus: parsed.isAdmin !== true && parsed.peer.kind === 'direct',
136
+ allowBareSessionReset: parsed.isAdmin !== true && parsed.peer.kind === 'direct',
132
137
  });
138
+ if (!command && parsed.peer.kind === 'direct') {
139
+ const unsupportedDirectCommand = parseBncrUnsupportedDirectCommand(extracted.text);
140
+ if (unsupportedDirectCommand) {
141
+ const rejectedRoute = buildBncrNativeCommandResolvedRoute({
142
+ api,
143
+ cfg,
144
+ channelId,
145
+ accountId,
146
+ peer,
147
+ resolvedAgentId,
148
+ });
149
+ const { baseSessionKey, sessionKey } = buildBncrNativeCommandSessionState({
150
+ parsed,
151
+ sessionAgentId: rejectedRoute.agentId || canonicalAgentId,
152
+ resolvedRoute: rejectedRoute,
153
+ });
154
+ const displayTo = formatDisplayScope(route);
155
+ logBncrNativeCommandSummary(
156
+ buildBncrNativeCommandSummary({
157
+ kind: 'unsupported',
158
+ command: unsupportedDirectCommand.command,
159
+ accountId,
160
+ to: displayTo,
161
+ msgId: msgId || null,
162
+ result: 'rejected',
163
+ }),
164
+ );
165
+ await enqueueFromReply({
166
+ accountId,
167
+ sessionKey: baseSessionKey,
168
+ route,
169
+ payload: {
170
+ text: `Unsupported private-chat command: /${unsupportedDirectCommand.command}`,
171
+ replyToId: msgId || undefined,
172
+ },
173
+ });
174
+ return { handled: true, command: unsupportedDirectCommand.command, sessionKey };
175
+ }
176
+ }
133
177
  if (!command) return { handled: false };
134
178
  const nativeCommandDebugEnabled = resolveNativeCommandDebugEnabled({ cfg, channelId });
135
179
 
@@ -213,6 +257,16 @@ export async function handleBncrNativeCommand(params: {
213
257
  isGroup: parsed.isGroup,
214
258
  isAdmin: parsed.isAdmin,
215
259
  });
260
+ const nativeStatus = resolveBncrNativeStatusCommand({
261
+ command,
262
+ accountId,
263
+ platform: parsed.platform,
264
+ userId: parsed.userId,
265
+ userName: parsed.userName,
266
+ resolvedAgentId: resolvedRoute.agentId || canonicalAgentId,
267
+ sessionKey,
268
+ });
269
+ const nativeSessionReset = resolveBncrNativeSessionResetCommand({ command });
216
270
  if (nativeHelp) {
217
271
  logBncrNativeCommandSummary(
218
272
  buildBncrNativeCommandSummary({
@@ -273,11 +327,42 @@ export async function handleBncrNativeCommand(params: {
273
327
  return { handled: true, command: command.command, sessionKey };
274
328
  }
275
329
 
276
- if (nativeVerbose) {
277
- if (!parsed.isAdmin) {
330
+ if (nativeStatus) {
331
+ logBncrNativeCommandSummary(
332
+ buildBncrNativeCommandSummary({
333
+ kind: 'status',
334
+ command: command.command,
335
+ accountId,
336
+ to: displayTo,
337
+ msgId: msgId || null,
338
+ result: 'handled',
339
+ }),
340
+ );
341
+ await recordAndPatchBncrInboundSessionEntry({
342
+ storePath,
343
+ sessionKey,
344
+ ctx: ctxPayload,
345
+ patch: sessionIdentityPatch,
346
+ });
347
+ rememberSessionRoute(baseSessionKey, accountId, route);
348
+ await enqueueFromReply({
349
+ accountId,
350
+ sessionKey,
351
+ route,
352
+ payload: {
353
+ text: nativeStatus.text,
354
+ replyToId: msgId || undefined,
355
+ },
356
+ });
357
+ return { handled: true, command: command.command, sessionKey };
358
+ }
359
+
360
+ if (nativeSessionReset) {
361
+ const allowLocalSessionReset = parsed.peer.kind === 'direct';
362
+ if (!allowLocalSessionReset) {
278
363
  logBncrNativeCommandSummary(
279
364
  buildBncrNativeCommandSummary({
280
- kind: 'verbose',
365
+ kind: nativeSessionReset.reason,
281
366
  command: command.command,
282
367
  accountId,
283
368
  to: displayTo,
@@ -292,15 +377,83 @@ export async function handleBncrNativeCommand(params: {
292
377
  patch: sessionIdentityPatch,
293
378
  });
294
379
  rememberSessionRoute(baseSessionKey, accountId, route);
295
- await enqueueFromReply({
380
+ return { handled: true, command: command.command, sessionKey };
381
+ }
382
+
383
+ const requestedAgentId = resolvedRoute.agentId || canonicalAgentId;
384
+ const requestApi = api as BncrInboundApi & {
385
+ request?: (method: string, params: Record<string, unknown>) => Promise<unknown>;
386
+ };
387
+ if (typeof requestApi.request !== 'function') {
388
+ throw new Error('OpenClaw plugin api.request is unavailable for sessions.reset');
389
+ }
390
+ await recordAndPatchBncrInboundSessionEntry({
391
+ storePath,
392
+ sessionKey,
393
+ ctx: ctxPayload,
394
+ patch: sessionIdentityPatch,
395
+ });
396
+ const resetResult = (await requestApi.request('sessions.reset', {
397
+ key: baseSessionKey,
398
+ reason: nativeSessionReset.reason,
399
+ ...(requestedAgentId ? { agentId: requestedAgentId } : {}),
400
+ })) as { ok?: boolean } | undefined;
401
+ if (resetResult?.ok === false) {
402
+ throw new Error(`sessions.reset failed for ${baseSessionKey}`);
403
+ }
404
+ logBncrNativeCommandSummary(
405
+ buildBncrNativeCommandSummary({
406
+ kind: nativeSessionReset.reason,
407
+ command: command.command,
296
408
  accountId,
409
+ to: displayTo,
410
+ msgId: msgId || null,
411
+ result: 'handled',
412
+ }),
413
+ );
414
+ rememberSessionRoute(baseSessionKey, accountId, route);
415
+ await enqueueFromReply({
416
+ accountId,
417
+ sessionKey: baseSessionKey,
418
+ route,
419
+ payload: {
420
+ text: nativeSessionReset.text,
421
+ replyToId: msgId || undefined,
422
+ },
423
+ });
424
+ return { handled: true, command: command.command, sessionKey: baseSessionKey };
425
+ }
426
+
427
+ if (nativeVerbose) {
428
+ if (!parsed.isAdmin) {
429
+ logBncrNativeCommandSummary(
430
+ buildBncrNativeCommandSummary({
431
+ kind: 'verbose',
432
+ command: command.command,
433
+ accountId,
434
+ to: displayTo,
435
+ msgId: msgId || null,
436
+ result: 'rejected',
437
+ }),
438
+ );
439
+ await recordAndPatchBncrInboundSessionEntry({
440
+ storePath,
297
441
  sessionKey,
298
- route,
299
- payload: {
300
- text: 'Admin permission required.',
301
- replyToId: msgId || undefined,
302
- },
442
+ ctx: ctxPayload,
443
+ patch: sessionIdentityPatch,
303
444
  });
445
+ rememberSessionRoute(baseSessionKey, accountId, route);
446
+ if (parsed.peer.kind !== 'group') {
447
+ await enqueueFromReply({
448
+ accountId,
449
+ sessionKey,
450
+ route,
451
+ payload: {
452
+ text: 'Admin permission required.',
453
+ replyToId: msgId || undefined,
454
+ },
455
+ });
456
+ }
304
457
  return { handled: true, command: command.command, sessionKey };
305
458
  }
306
459
 
@@ -350,6 +503,7 @@ export async function handleBncrNativeCommand(params: {
350
503
 
351
504
  const sceneAdmin = parseSceneAdminCommand(command);
352
505
  if (sceneAdmin.matched) {
506
+ const silentNonAdminGroupReject = !parsed.isAdmin && parsed.peer.kind === 'group';
353
507
  if (!sceneAdmin.valid) {
354
508
  logBncrNativeCommandSummary(
355
509
  buildBncrNativeCommandSummary({
@@ -367,15 +521,17 @@ export async function handleBncrNativeCommand(params: {
367
521
  ctx: ctxPayload,
368
522
  patch: sessionIdentityPatch,
369
523
  });
370
- await enqueueFromReply({
371
- accountId,
372
- sessionKey,
373
- route,
374
- payload: {
375
- text: sceneAdmin.text,
376
- replyToId: msgId || undefined,
377
- },
378
- });
524
+ if (!silentNonAdminGroupReject) {
525
+ await enqueueFromReply({
526
+ accountId,
527
+ sessionKey,
528
+ route,
529
+ payload: {
530
+ text: sceneAdmin.text,
531
+ replyToId: msgId || undefined,
532
+ },
533
+ });
534
+ }
379
535
  return { handled: true, command: command.command, sessionKey };
380
536
  }
381
537
 
@@ -403,15 +559,17 @@ export async function handleBncrNativeCommand(params: {
403
559
  ctx: ctxPayload,
404
560
  patch: sessionIdentityPatch,
405
561
  });
406
- await enqueueFromReply({
407
- accountId,
408
- sessionKey,
409
- route,
410
- payload: {
411
- text: outcome.text,
412
- replyToId: msgId || undefined,
413
- },
414
- });
562
+ if (!(silentNonAdminGroupReject && !outcome.ok)) {
563
+ await enqueueFromReply({
564
+ accountId,
565
+ sessionKey,
566
+ route,
567
+ payload: {
568
+ text: outcome.text,
569
+ replyToId: msgId || undefined,
570
+ },
571
+ });
572
+ }
415
573
  return { handled: true, command: command.command, sessionKey };
416
574
  }
417
575
 
@@ -7,6 +7,8 @@ export type NativeCommand = {
7
7
 
8
8
  export type ParseBncrNativeCommandOptions = {
9
9
  allowBareWhoami?: boolean;
10
+ allowBareStatus?: boolean;
11
+ allowBareSessionReset?: boolean;
10
12
  };
11
13
 
12
14
  export type NativeVerboseCommand = {
@@ -25,6 +27,17 @@ export type NativeWhoamiCommand = {
25
27
  text: string;
26
28
  };
27
29
 
30
+ export type NativeStatusCommand = {
31
+ handled: true;
32
+ text: string;
33
+ };
34
+
35
+ export type NativeSessionResetCommand = {
36
+ handled: true;
37
+ reason: 'new' | 'reset';
38
+ text: string;
39
+ };
40
+
28
41
  const BNCR_HELP_TEXT = [
29
42
  '🦞 Bncr command usage',
30
43
  '',
@@ -33,19 +46,22 @@ const BNCR_HELP_TEXT = [
33
46
  ' • /bncr verbose on|off|full',
34
47
  '',
35
48
  '🛡 Scene approval',
36
- ' • /bncr allow [<platform>:<groupId>]',
37
- ' • /bncr deny [<platform>:<groupId>]',
38
- ' • /bncr bind <agentId> [<platform>:<groupId>]',
49
+ ' • /bncr allow [<SceneId>]',
50
+ ' • /bncr deny [<SceneId>]',
51
+ ' • /bncr bind <agentId> [<SceneId>]',
39
52
  ' • /bncr mode',
40
53
  ' • /bncr mode help',
41
- ' • /bncr mode <admin|mention|hybrid|all> [<platform>:<groupId>]',
42
- ' • /bncr revoke [<platform>:<groupId>]',
54
+ ' • /bncr mode <admin|mention|hybrid|all> [<SceneId>]',
55
+ ' • /bncr revoke [<SceneId>]',
43
56
  ' • /bncr list pending',
44
57
  ' • /bncr list scenes',
45
58
  ].join('\n');
46
59
 
47
60
  const BNCR_NATIVE_COMMANDS = new Set([
48
61
  'help',
62
+ 'new',
63
+ 'reset',
64
+ 'status',
49
65
  'whoami',
50
66
  'verbose',
51
67
  'allow',
@@ -56,12 +72,33 @@ const BNCR_NATIVE_COMMANDS = new Set([
56
72
  'list',
57
73
  ]);
58
74
 
75
+ export const BNCR_DIRECT_ALLOWED_BARE_COMMANDS = new Set(['whoami', 'status', 'new', 'reset']);
76
+
77
+ export const BNCR_DIRECT_ALLOWED_SUBCOMMANDS = new Set([
78
+ 'help',
79
+ 'whoami',
80
+ 'status',
81
+ 'new',
82
+ 'reset',
83
+ ]);
84
+
59
85
  export function parseBncrNativeCommand(
60
86
  text: string,
61
87
  options?: ParseBncrNativeCommandOptions,
62
88
  ): NativeCommand | null {
63
89
  const raw = String(text || '').trim();
64
90
  const allowBareWhoami = options?.allowBareWhoami !== false;
91
+ const allowBareStatus = options?.allowBareStatus === true;
92
+ const allowBareSessionReset = options?.allowBareSessionReset === true;
93
+ if (allowBareSessionReset && raw.toLowerCase() === '/new') {
94
+ return { command: 'new', raw, body: '/new', argsText: '' };
95
+ }
96
+ if (allowBareSessionReset && raw.toLowerCase() === '/reset') {
97
+ return { command: 'reset', raw, body: '/reset', argsText: '' };
98
+ }
99
+ if (allowBareStatus && raw.toLowerCase() === '/status') {
100
+ return { command: 'status', raw, body: '/status', argsText: '' };
101
+ }
65
102
  if (allowBareWhoami && raw.toLowerCase() === '/whoami') {
66
103
  return { command: 'whoami', raw, body: '/whoami', argsText: '' };
67
104
  }
@@ -81,6 +118,34 @@ export function parseBncrNativeCommand(
81
118
  return { command, raw, body, argsText };
82
119
  }
83
120
 
121
+ export function parseBncrUnsupportedDirectCommand(text: string): {
122
+ command: string;
123
+ raw: string;
124
+ } | null {
125
+ const raw = String(text || '').trim();
126
+ if (!raw.startsWith('/')) return null;
127
+
128
+ const bareMatch = raw.match(/^\/([A-Za-z0-9_]+)(?:@[A-Za-z0-9_]+)?(?:\s+.*)?$/);
129
+ if (!bareMatch) return null;
130
+ const bareCommand = String(bareMatch[1] || '')
131
+ .trim()
132
+ .toLowerCase();
133
+ if (!bareCommand) return null;
134
+
135
+ if (bareCommand === 'bncr') {
136
+ const subMatch = raw.match(/^\/bncr(?:@[A-Za-z0-9_]+)?(?:\s+([A-Za-z0-9_-]+))?/i);
137
+ const subCommand =
138
+ String(subMatch?.[1] || '')
139
+ .trim()
140
+ .toLowerCase() || 'help';
141
+ if (BNCR_DIRECT_ALLOWED_SUBCOMMANDS.has(subCommand)) return null;
142
+ return { command: `bncr ${subCommand}`, raw };
143
+ }
144
+
145
+ if (BNCR_DIRECT_ALLOWED_BARE_COMMANDS.has(bareCommand)) return null;
146
+ return { command: bareCommand, raw };
147
+ }
148
+
84
149
  export function resolveBncrNativeHelpCommand(command: NativeCommand): NativeHelpCommand | null {
85
150
  if (command.command !== 'help') return null;
86
151
  return { handled: true, text: BNCR_HELP_TEXT };
@@ -110,6 +175,41 @@ export function resolveBncrNativeWhoamiCommand(args: {
110
175
  return { handled: true, text: lines.join('\n') };
111
176
  }
112
177
 
178
+ export function resolveBncrNativeStatusCommand(args: {
179
+ command: NativeCommand;
180
+ accountId: string;
181
+ platform: string;
182
+ userId: string;
183
+ userName?: string;
184
+ resolvedAgentId: string;
185
+ sessionKey: string;
186
+ }): NativeStatusCommand | null {
187
+ if (args.command.command !== 'status') return null;
188
+ const lines = ['🦞 Bncr Status', ''];
189
+ lines.push(`Channel: bncr`);
190
+ lines.push(`Account: ${args.accountId || 'Primary'}`);
191
+ lines.push(`User: ${args.userName || '(unknown)'} (${args.userId || '0'})`);
192
+ lines.push(`Scene: ${args.platform || '(unknown)'}:${args.userId || '0'}`);
193
+ lines.push(`Agent: ${args.resolvedAgentId || '(unknown)'}`);
194
+ lines.push(`SessionKey: ${args.sessionKey || '(unknown)'}`);
195
+ return { handled: true, text: lines.join('\n') };
196
+ }
197
+
198
+ export function resolveBncrNativeSessionResetCommand(args: {
199
+ command: NativeCommand;
200
+ }): NativeSessionResetCommand | null {
201
+ if (args.command.command !== 'new' && args.command.command !== 'reset') return null;
202
+ const reason = args.command.command === 'new' ? 'new' : 'reset';
203
+ return {
204
+ handled: true,
205
+ reason,
206
+ text:
207
+ reason === 'new'
208
+ ? 'Started a new session for this private chat.'
209
+ : 'Reset the current session for this private chat.',
210
+ };
211
+ }
212
+
113
213
  export function resolveBncrNativeVerboseCommand(
114
214
  command: NativeCommand,
115
215
  ): NativeVerboseCommand | null {
@@ -208,130 +208,143 @@ export async function runBncrInboundReplyDispatch(args: {
208
208
  return;
209
209
  }
210
210
 
211
- await runBncrReplyDispatchSerial(
212
- resolution.dispatchSessionKey,
213
- async () =>
214
- resolveBncrChannelInboundRuntime(api).run({
215
- channel: channelId,
216
- accountId: resolution.accountId,
217
- raw: parsed,
218
- adapter: {
219
- ingest: () => ({
220
- id: msgId ?? `${resolution.canonicalTo}:${Date.now()}`,
221
- timestamp: Date.now(),
222
- rawText: rawBody,
223
- textForAgent: ctxPayload.BodyForAgent,
224
- textForCommands: ctxPayload.CommandBody,
225
- raw: parsed,
226
- }),
227
- preflight: () =>
228
- shouldDispatch
229
- ? undefined
230
- : {
231
- admission: {
232
- kind: 'observeOnly' as const,
233
- reason: 'bncr-group-mode-no-reply',
234
- },
235
- },
236
- resolveTurn: () => {
237
- const sessionMetaBarrier = createBncrSessionMetaTaskBarrier();
238
- return {
239
- channel: channelId,
240
- accountId: resolution.accountId,
241
- routeSessionKey: resolution.resolvedRoute.sessionKey,
242
- storePath,
243
- ctxPayload,
244
- recordInboundSession: wrapBncrInboundRecordSessionLabelCorrection({
245
- recordInboundSession: recordBncrInboundSession as (
246
- ...args: unknown[]
247
- ) => Promise<unknown> | unknown,
248
- expectedPatch: sessionIdentityPatch,
249
- }),
250
- record: {
251
- updateLastRoute,
252
- onRecordError: (err: unknown) => {
253
- emitBncrLogLine('warn', `[bncr] inbound record session failed: ${String(err)}`);
254
- },
255
- trackSessionMetaTask: (task: Promise<unknown>) => {
256
- sessionMetaBarrier.track(task);
257
- },
258
- },
259
- runDispatch: async () => {
260
- await sessionMetaBarrier.wait();
261
- await correctBncrInboundSessionLabel({
262
- storePath,
263
- sessionKey: resolution.dispatchSessionKey,
264
- expectedPatch: sessionIdentityPatch,
265
- });
266
- return Promise.resolve(
267
- dispatchOpenClawReplyWithBufferedBlockDispatcher(api, {
268
- ctx: ctxPayload,
269
- cfg: buildBncrReplyConfig(commandDispatchCfg).replyCfg,
270
- dispatcherOptions: {
271
- deliver: async (
272
- payload: {
273
- text?: string;
274
- mediaUrl?: string;
275
- mediaUrls?: string[];
276
- audioAsVoice?: boolean;
277
- },
278
- info?: { kind?: 'tool' | 'block' | 'final' },
279
- ) => {
280
- const kind = info?.kind;
281
- const shouldForwardTool =
282
- effectiveReply.blockStreaming && effectiveReply.allowTool;
283
-
284
- if (kind === 'tool' && !shouldForwardTool) {
285
- return;
286
- }
211
+ // Stop commands should bypass the serial chain so they can interrupt a running agent.
212
+ const rawTrimmed = (rawBody || '').trim();
213
+ const isStopCommand =
214
+ rawTrimmed === '/stop' || rawTrimmed.startsWith('/stop ') || rawTrimmed.startsWith('/stop@');
215
+ const dispatchSessionKey = resolution.dispatchSessionKey;
287
216
 
288
- await enqueueFromReply({
289
- accountId: replyRouteFact.accountId,
290
- sessionKey: replyRouteFact.sessionKey,
291
- route: replyRouteFact.route,
292
- payload: {
293
- text: payload.text,
294
- mediaUrl: payload.mediaUrl,
295
- mediaUrls: payload.mediaUrls,
296
- kind: kind || 'final',
297
- replyToId: msgId || undefined,
298
- },
299
- });
300
- },
301
- onError: (err: unknown) => {
302
- emitBncrLogLine('error', `[bncr] outbound reply failed: ${String(err)}`);
303
- },
304
- },
305
- replyOptions: {
306
- disableBlockStreaming: !effectiveReply.blockStreaming,
307
- shouldEmitToolResult: effectiveReply.allowTool ? () => true : undefined,
217
+ const runStopOrSerial = () => {
218
+ const task = () =>
219
+ Promise.resolve(
220
+ resolveBncrChannelInboundRuntime(api).run({
221
+ channel: channelId,
222
+ accountId: resolution.accountId,
223
+ raw: parsed,
224
+ adapter: {
225
+ ingest: () => ({
226
+ id: msgId ?? `${resolution.canonicalTo}:${Date.now()}`,
227
+ timestamp: Date.now(),
228
+ rawText: rawBody,
229
+ textForAgent: ctxPayload.BodyForAgent,
230
+ textForCommands: ctxPayload.CommandBody,
231
+ raw: parsed,
232
+ }),
233
+ preflight: () =>
234
+ shouldDispatch
235
+ ? undefined
236
+ : {
237
+ admission: {
238
+ kind: 'observeOnly' as const,
239
+ reason: 'bncr-group-mode-no-reply',
308
240
  },
309
- }),
310
- ).finally(async () => {
311
- await waitForBncrReplySessionQuiescence({
312
- api,
313
- storePath,
314
- sessionKey: resolution.dispatchSessionKey,
315
- msgId,
316
- to: resolution.canonicalTo,
317
- debugEnabled: cfg?.channels?.bncr?.debug?.verbose === true,
318
- });
241
+ },
242
+ resolveTurn: () => {
243
+ const sessionMetaBarrier = createBncrSessionMetaTaskBarrier();
244
+ return {
245
+ channel: channelId,
246
+ accountId: resolution.accountId,
247
+ routeSessionKey: resolution.resolvedRoute.sessionKey,
248
+ storePath,
249
+ ctxPayload,
250
+ recordInboundSession: wrapBncrInboundRecordSessionLabelCorrection({
251
+ recordInboundSession: recordBncrInboundSession as (
252
+ ...args: unknown[]
253
+ ) => Promise<unknown> | unknown,
254
+ expectedPatch: sessionIdentityPatch,
255
+ }),
256
+ record: {
257
+ updateLastRoute,
258
+ onRecordError: (err: unknown) => {
259
+ emitBncrLogLine('warn', `[bncr] inbound record session failed: ${String(err)}`);
260
+ },
261
+ trackSessionMetaTask: (task: Promise<unknown>) => {
262
+ sessionMetaBarrier.track(task);
263
+ },
264
+ },
265
+ runDispatch: async () => {
266
+ await sessionMetaBarrier.wait();
319
267
  await correctBncrInboundSessionLabel({
320
268
  storePath,
321
- sessionKey: resolution.dispatchSessionKey,
269
+ sessionKey: dispatchSessionKey,
322
270
  expectedPatch: sessionIdentityPatch,
323
271
  });
324
- });
325
- },
326
- };
327
- },
328
- onFinalize: () => {
329
- const inboundAt = Date.now();
330
- setInboundActivity(resolution.accountId, inboundAt);
331
- scheduleSave();
272
+ return Promise.resolve(
273
+ dispatchOpenClawReplyWithBufferedBlockDispatcher(api, {
274
+ ctx: ctxPayload,
275
+ cfg: buildBncrReplyConfig(commandDispatchCfg).replyCfg,
276
+ dispatcherOptions: {
277
+ deliver: async (
278
+ payload: {
279
+ text?: string;
280
+ mediaUrl?: string;
281
+ mediaUrls?: string[];
282
+ audioAsVoice?: boolean;
283
+ },
284
+ info?: { kind?: 'tool' | 'block' | 'final' },
285
+ ) => {
286
+ const kind = info?.kind;
287
+ const shouldForwardTool =
288
+ effectiveReply.blockStreaming && effectiveReply.allowTool;
289
+
290
+ if (kind === 'tool' && !shouldForwardTool) {
291
+ return;
292
+ }
293
+
294
+ await enqueueFromReply({
295
+ accountId: replyRouteFact.accountId,
296
+ sessionKey: replyRouteFact.sessionKey,
297
+ route: replyRouteFact.route,
298
+ payload: {
299
+ text: payload.text,
300
+ mediaUrl: payload.mediaUrl,
301
+ mediaUrls: payload.mediaUrls,
302
+ kind: kind || 'final',
303
+ replyToId: msgId || undefined,
304
+ },
305
+ });
306
+ },
307
+ onError: (err: unknown) => {
308
+ emitBncrLogLine('error', `[bncr] outbound reply failed: ${String(err)}`);
309
+ },
310
+ },
311
+ replyOptions: {
312
+ disableBlockStreaming: !effectiveReply.blockStreaming,
313
+ shouldEmitToolResult: effectiveReply.allowTool ? () => true : undefined,
314
+ },
315
+ }),
316
+ );
317
+ },
318
+ };
319
+ },
320
+ onFinalize: () => {
321
+ const inboundAt = Date.now();
322
+ setInboundActivity(resolution.accountId, inboundAt);
323
+ scheduleSave();
324
+ },
332
325
  },
333
- },
334
- }),
335
- { msgId, to: resolution.canonicalTo },
336
- );
326
+ }),
327
+ ).finally(async () => {
328
+ await waitForBncrReplySessionQuiescence({
329
+ api,
330
+ storePath,
331
+ sessionKey: dispatchSessionKey,
332
+ msgId,
333
+ to: resolution.canonicalTo,
334
+ debugEnabled: cfg?.channels?.bncr?.debug?.verbose === true,
335
+ });
336
+ await correctBncrInboundSessionLabel({
337
+ storePath,
338
+ sessionKey: dispatchSessionKey,
339
+ expectedPatch: sessionIdentityPatch,
340
+ });
341
+ });
342
+
343
+ if (isStopCommand) {
344
+ return task();
345
+ }
346
+ return runBncrReplyDispatchSerial(dispatchSessionKey, task);
347
+ };
348
+
349
+ await runStopOrSerial();
337
350
  }
@@ -146,15 +146,46 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
146
146
  }
147
147
  }
148
148
 
149
- function formatSceneLine(scene: BncrSceneRecord): string {
149
+ function formatSceneDetailsLine(scene: BncrSceneRecord): string {
150
150
  const idPart =
151
151
  scene.kind === 'group' ? scene.groupId || scene.sceneKey : scene.userId || scene.sceneKey;
152
152
  const namePart = scene.kind === 'group' ? scene.groupName || '' : scene.userName || '';
153
- const agentPart = scene.agentId ? ` agent=${scene.agentId}` : '';
154
- const modePart =
155
- scene.kind === 'group' && scene.groupReplyMode ? ` mode=${scene.groupReplyMode}` : '';
156
153
  const labelPart = namePart ? ` name=${namePart}` : '';
157
- return `${scene.sceneKey} status=${scene.status} kind=${scene.kind} id=${idPart}${labelPart}${agentPart}${modePart}`;
154
+ return ` Details: status=${scene.status} id=${idPart}${labelPart}`;
155
+ }
156
+
157
+ function formatSceneEntry(scene: BncrSceneRecord): string {
158
+ return [` SceneId: ${scene.sceneKey}`, formatSceneDetailsLine(scene)].join('\n');
159
+ }
160
+
161
+ function buildSceneGroupTitle(scene: BncrSceneRecord): string {
162
+ const agentId = normalizeToken(scene.agentId || '') || 'public';
163
+ if (scene.kind === 'group') {
164
+ const mode = normalizeToken(scene.groupReplyMode || '') || 'admin';
165
+ return `👥 Group Chat ${agentId} ${mode}`;
166
+ }
167
+ return `📱 Private Chat ${agentId}`;
168
+ }
169
+
170
+ function formatSceneGroups(scenes: BncrSceneRecord[]): string {
171
+ const directGrouped = new Map<string, string[]>();
172
+ const groupGrouped = new Map<string, string[]>();
173
+ for (const scene of scenes) {
174
+ const title = buildSceneGroupTitle(scene);
175
+ const target = scene.kind === 'group' ? groupGrouped : directGrouped;
176
+ const current = target.get(title) || [];
177
+ current.push(formatSceneEntry(scene));
178
+ target.set(title, current);
179
+ }
180
+
181
+ const orderedGroups = [
182
+ ...Array.from(directGrouped.entries()),
183
+ ...Array.from(groupGrouped.entries()).sort(([left], [right]) => left.localeCompare(right)),
184
+ ];
185
+
186
+ return orderedGroups
187
+ .map(([title, entries]) => [title, '', entries.join('\n\n')].join('\n'))
188
+ .join('\n\n');
158
189
  }
159
190
 
160
191
  function applySceneStatus(scene: BncrSceneRecord, status: BncrSceneStatus): BncrSceneRecord {
@@ -188,7 +219,7 @@ export function executeSceneAdminCommand(args: {
188
219
  text: command.scope === 'pending' ? 'No pending scenes.' : 'No scenes recorded.',
189
220
  };
190
221
  }
191
- return { ok: true, text: scenes.map(formatSceneLine).join('\n') };
222
+ return { ok: true, text: formatSceneGroups(scenes) };
192
223
  }
193
224
 
194
225
  if (command.kind === 'mode-help') {
@@ -266,6 +266,7 @@ export function createBncrInboundHandlersComponent(runtime: {
266
266
  prepareInboundAcceptance: runtime.prepareInboundAcceptance,
267
267
  formatDisplayScope,
268
268
  logInboundSummary: runtime.logInboundSummary,
269
+ enqueueFromReply: runtime.enqueueFromReply,
269
270
  flushOnInboundAccepted: (accountId) =>
270
271
  runtime.flushPushQueueBestEffort({
271
272
  accountId,
@@ -1,7 +1,9 @@
1
1
  import type { GatewayRequestHandlerOptions } from 'openclaw/plugin-sdk/core';
2
+ import { buildCanonicalBncrSessionKey } from '../core/targets.ts';
2
3
  import type { BncrConnection, BncrRoute } from '../core/types.ts';
3
4
  import type { BncrGroupHistoryMap } from '../messaging/inbound/group-history.ts';
4
5
  import type { parseBncrInboundParams } from '../messaging/inbound/parse.ts';
6
+ import type { ReplyPayloadInput } from '../messaging/outbound/reply-enqueue.ts';
5
7
  import type {
6
8
  buildInboundResponsePayload,
7
9
  resolveInboundSessionContext,
@@ -50,6 +52,26 @@ type InboundAcceptanceResult =
50
52
  payload: Record<string, unknown>;
51
53
  };
52
54
 
55
+ function buildBncrPendingDirectApprovalNotice(args: {
56
+ parsed: ParsedInboundParams;
57
+ reason: string;
58
+ }): string | null {
59
+ if (args.parsed.peer.kind !== 'direct') return null;
60
+ if (args.reason !== 'scene pending approval') return null;
61
+ return `This private chat is pending approval. Ask the administrator to allow your SceneId: ${args.parsed.platform}:${args.parsed.userId}`;
62
+ }
63
+
64
+ function buildBncrPendingDirectApprovalPayload(args: {
65
+ msgId?: string;
66
+ text: string;
67
+ }): ReplyPayloadInput {
68
+ return {
69
+ text: args.text,
70
+ kind: 'final',
71
+ ...(args.msgId ? { replyToId: args.msgId } : {}),
72
+ };
73
+ }
74
+
53
75
  export type BncrInboundHandlersRuntime = {
54
76
  channelId: string;
55
77
  bridgeId: string;
@@ -123,6 +145,13 @@ export type BncrInboundHandlersRuntime = {
123
145
  text: string;
124
146
  hasMedia: boolean;
125
147
  }) => void;
148
+ enqueueFromReply: (args: {
149
+ accountId: string;
150
+ sessionKey: string;
151
+ route: BncrRoute;
152
+ payload: ReplyPayloadInput;
153
+ mediaLocalRoots?: readonly string[];
154
+ }) => Promise<void>;
126
155
  respond: GatewayRequestHandlerOptions['respond'];
127
156
  flushOnInboundAccepted: (accountId: string) => void;
128
157
  dispatchInbound: (args: {
@@ -227,6 +256,19 @@ export function createBncrInboundHandlers(runtime: Omit<BncrInboundHandlersRunti
227
256
  'inbound',
228
257
  `accept reject|accountId=${accountId}|msgId=${msgId ?? '-'}|scope=${runtime.formatDisplayScope(route)}|chatType=${peer.kind}|msgType=${msgType}|status=${acceptance.status}|reason=${reason || '-'}|isAdmin=${parsed.isAdmin === true}|shouldRespond=${parsed.shouldRespond === true}`,
229
258
  );
259
+ const pendingNotice = buildBncrPendingDirectApprovalNotice({ parsed, reason });
260
+ if (pendingNotice) {
261
+ const sessionKey = buildCanonicalBncrSessionKey(route, canonicalAgentId);
262
+ await runtime.enqueueFromReply({
263
+ accountId,
264
+ sessionKey,
265
+ route,
266
+ payload: buildBncrPendingDirectApprovalPayload({
267
+ msgId: msgId ?? undefined,
268
+ text: pendingNotice,
269
+ }),
270
+ });
271
+ }
230
272
  respond(acceptance.status, acceptance.payload);
231
273
  return;
232
274
  }
@@ -71,14 +71,20 @@ export function decideSceneAdmission(args: {
71
71
  const sceneKey = buildSceneKey(parsed);
72
72
  const kind = asSceneKind(parsed);
73
73
  const existing = sceneRegistry.get(sceneKey);
74
+ const shouldForceDirectNonAdminPublic = (agentId?: string) =>
75
+ kind === 'direct' && parsed.isAdmin !== true && (!agentId || agentId === defaultAdminAgentId);
74
76
 
75
77
  if (existing) {
78
+ const forcedAgentId = shouldForceDirectNonAdminPublic(existing.agentId)
79
+ ? defaultPublicAgentId
80
+ : existing.agentId;
76
81
  const nextScene = {
77
82
  ...existing,
78
83
  ...(parsed.userId ? { userId: parsed.userId } : {}),
79
84
  ...(parsed.userName ? { userName: parsed.userName } : {}),
80
85
  ...(kind === 'group' && parsed.groupId ? { groupId: parsed.groupId } : {}),
81
86
  ...(kind === 'group' && parsed.groupName ? { groupName: parsed.groupName } : {}),
87
+ ...(forcedAgentId ? { agentId: forcedAgentId } : {}),
82
88
  ...(kind === 'group' && !existing.groupReplyMode
83
89
  ? { groupReplyMode: defaultGroupReplyMode(kind) }
84
90
  : {}),
@@ -137,19 +143,29 @@ export function decideSceneAdmission(args: {
137
143
  return { allowed: true, scene, agentId };
138
144
  }
139
145
 
146
+ const directNonAdminAllowed = kind === 'direct';
140
147
  const scene = buildSceneRecord({
141
148
  parsed,
142
149
  sceneKey,
143
150
  kind,
144
- status: kind === 'direct' ? 'pending' : 'denied',
151
+ status: directNonAdminAllowed ? 'allowed' : 'denied',
152
+ agentId: directNonAdminAllowed ? defaultPublicAgentId : undefined,
145
153
  lastSeenAt: now,
146
154
  });
147
155
  sceneRegistry.set(sceneKey, scene);
148
156
 
157
+ if (directNonAdminAllowed) {
158
+ return {
159
+ allowed: true,
160
+ scene,
161
+ agentId: scene.agentId || defaultPublicAgentId,
162
+ };
163
+ }
164
+
149
165
  return {
150
166
  allowed: false,
151
167
  scene,
152
- reason: kind === 'direct' ? 'scene pending approval' : 'scene denied',
153
- replyPolicy: kind === 'direct' ? 'pending' : 'silent',
168
+ reason: 'scene denied',
169
+ replyPolicy: 'silent',
154
170
  };
155
171
  }