@xmoxmo/bncr 0.6.2 → 0.6.3

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
@@ -93,9 +93,73 @@ openclaw devices approve --latest
93
93
  - 入站会话按正式 scene / route 语义接入 OpenClaw,区分私聊与群聊会话。
94
94
  - 非管理员私聊支持受控的本地命令能力:`/whoami`、`/status`、`/new`、`/reset`,以及对应的 `/bncr ...` 入口。
95
95
  - 非管理员私聊中,不在支持范围内的 slash 命令会由 bncr 直接拒绝,不再 fallback 到 agent。
96
- - 群聊里的管理型 / 控制型命令对非管理员按静默拒绝处理,不给用户可见回复。
96
+ - 群聊非管理员执行场景管理命令(`mode` / `allow` / `deny` / `bind` / `revoke` / `list` / `history-*` / `download-media`)时,统一返回 `Admin permission required.`,不再附带用法提示。
97
+ - 群聊非管理员执行 `/bncr new`、`/bncr reset`、`/bncr verbose` 时仍按静默拒绝处理。
97
98
  - `/bncr list pending` 与 `/bncr list scenes` 采用按私聊 / 群聊分组的摘要展示格式,便于现场排查 scene 状态。
98
99
 
100
+ ### `/bncr help` 权限过滤
101
+
102
+ `/bncr help` 现在按当前身份动态展示:
103
+
104
+ - 管理员:展示全部命令
105
+ - 私聊非管理员:展示当前可用命令,不展示管理类命令
106
+ - 群聊非管理员:仅展示基础命令,不展示 `mode`、`history-*`、`download-media` 等管理命令
107
+
108
+ 示例:
109
+
110
+ 管理员:
111
+
112
+ ```text
113
+ 🦞 Bncr command usage
114
+
115
+ 📌 Bncr builtins
116
+ • /bncr whoami
117
+ • /bncr status
118
+ • /bncr new
119
+ • /bncr reset
120
+ • /bncr verbose on|off|full
121
+
122
+ 🛡 Scene approval
123
+ • /bncr allow [<SceneId>]
124
+ • /bncr deny [<SceneId>]
125
+ • /bncr bind <agentId> [<SceneId>]
126
+ • /bncr mode help
127
+ • /bncr mode <admin|mention|hybrid|all|clear> [<SceneId>]
128
+ • /bncr revoke [<SceneId>]
129
+ • /bncr list pending [filters...]
130
+ • /bncr list scenes [filters...]
131
+
132
+ 📋 Conversation history
133
+ • /bncr history-help
134
+ • /bncr history-limit [<number>|clear] [<SceneId>]
135
+ • /bncr history-force on|off|clear [<SceneId>]
136
+
137
+ 🌐 Remote media
138
+ • /bncr download-media on|off|clear|default on|off [<SceneId>]
139
+ ```
140
+
141
+ 私聊非管理员:
142
+
143
+ ```text
144
+ 🦞 Bncr command usage
145
+
146
+ 📌 Bncr builtins
147
+ • /bncr whoami
148
+ • /bncr status
149
+ • /bncr new
150
+ • /bncr reset
151
+ ```
152
+
153
+ 群聊非管理员:
154
+
155
+ ```text
156
+ 🦞 Bncr command usage
157
+
158
+ 📌 Bncr builtins
159
+ • /bncr whoami
160
+ • /bncr status
161
+ ```
162
+
99
163
  ---
100
164
 
101
165
  ## 5. 架构定位
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmoxmo/bncr",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -158,6 +158,7 @@ const requiredSourceFiles = [
158
158
  'src/plugin/setup.ts',
159
159
  'src/plugin/state-transient-runtime-group.ts',
160
160
  'src/plugin/state-store.ts',
161
+ 'src/plugin/sqlite-state.ts',
161
162
  'src/plugin/target-runtime.ts',
162
163
  'src/plugin/target-status-runtime-group.ts',
163
164
  'src/plugin/transient-state-runtime.ts',
@@ -175,6 +176,8 @@ const requiredSourceFiles = [
175
176
  'src/core/value-sanitize.ts',
176
177
  'src/messaging/inbound/contracts.ts',
177
178
  'src/messaging/inbound/conversation-history.ts',
179
+ 'src/messaging/inbound/conversation-history-serial.ts',
180
+ 'src/messaging/inbound/history-shard-worker.ts',
178
181
  'src/messaging/inbound/native-command-runtime.ts',
179
182
  'src/messaging/inbound/reply-dispatch-serial.ts',
180
183
  'src/messaging/inbound/scene-admin.ts',
@@ -64,6 +64,8 @@ const requiredSourceFiles = [
64
64
  'src/messaging/inbound/dispatch.ts',
65
65
  'src/messaging/inbound/gate.ts',
66
66
  'src/messaging/inbound/conversation-history.ts',
67
+ 'src/messaging/inbound/conversation-history-serial.ts',
68
+ 'src/messaging/inbound/history-shard-worker.ts',
67
69
  'src/messaging/inbound/last-route.ts',
68
70
  'src/messaging/inbound/media-url-download.ts',
69
71
  'src/messaging/inbound/native-command-runtime.ts',
@@ -191,6 +193,7 @@ const requiredSourceFiles = [
191
193
  'src/plugin/scene-registry.ts',
192
194
  'src/plugin/setup.ts',
193
195
  'src/plugin/state-store.ts',
196
+ 'src/plugin/sqlite-state.ts',
194
197
  'src/plugin/state-transient-runtime-group.ts',
195
198
  'src/plugin/status-runtime.ts',
196
199
  'src/plugin/status.ts',
package/src/channel.ts CHANGED
@@ -4,7 +4,6 @@ import type {
4
4
  OpenClawPluginApi,
5
5
  OpenClawPluginServiceContext,
6
6
  } from 'openclaw/plugin-sdk/core';
7
- import { DEFAULT_GROUP_HISTORY_LIMIT as DEFAULT_HISTORY_LIMIT } from 'openclaw/plugin-sdk/reply-history';
8
7
  import {
9
8
  BNCR_DEFAULT_ACCOUNT_ID,
10
9
  CHANNEL_ID,
@@ -44,9 +43,22 @@ import type {
44
43
  FileSendTransferState,
45
44
  OutboxEntry,
46
45
  } from './core/types.ts';
47
- import type { BncrConversationHistoryMap } from './messaging/inbound/conversation-history.ts';
46
+ import {
47
+ type BncrConversationHistoryMap,
48
+ resolveBncrHistoryLimit,
49
+ } from './messaging/inbound/conversation-history.ts';
50
+ import {
51
+ clearConversationHistorySerialLocks,
52
+ readConversationHistorySerialHistoryKeys,
53
+ } from './messaging/inbound/conversation-history-serial.ts';
54
+ import { dispatchBncrOutboundHistoryFlush } from './messaging/inbound/dispatch.ts';
55
+ import {
56
+ type BncrHistoryShardDispatchPayload,
57
+ processBncrHistoryShardSlot,
58
+ } from './messaging/inbound/history-shard-worker.ts';
48
59
  import type { BncrOutboundReplayCache } from './messaging/inbound/outbound-replay-cache.ts';
49
60
  import type { parseBncrInboundParams } from './messaging/inbound/parse.ts';
61
+ import { runBncrInboundReplyDispatch } from './messaging/inbound/reply-dispatch.ts';
50
62
  import { buildEnqueueFromReplyDebugInfo } from './messaging/outbound/diagnostics.ts';
51
63
  import { buildBncrMediaOutboundFrame } from './messaging/outbound/media.ts';
52
64
  import {
@@ -127,6 +139,8 @@ import {
127
139
  FILE_TRANSFER_ACK_TTL_MS,
128
140
  FILE_TRANSFER_KEEP_MS,
129
141
  FILE_TRANSFER_TERMINAL_KEEP_MS,
142
+ HISTORY_SHARD_WORKER_BATCH_LIMIT,
143
+ HISTORY_SHARD_WORKER_INTERVAL_MS,
130
144
  INBOUND_FILE_TRANSFER_MAX_BYTES,
131
145
  INBOUND_FILE_TRANSFER_MAX_CHUNKS,
132
146
  MAX_ACCOUNT_ACTIVITY_ENTRIES,
@@ -188,6 +202,7 @@ import {
188
202
  createBncrRuntimeAckObservabilityBuilder,
189
203
  } from './plugin/runtime-diagnostics-snapshot.ts';
190
204
  import { BNCR_SETUP_SURFACE } from './plugin/setup.ts';
205
+ import type { BncrHistoryShardRow } from './plugin/sqlite-state.ts';
191
206
  import { createBncrStateTransientRuntimeGroup } from './plugin/state-transient-runtime-group.ts';
192
207
  import { createBncrTargetStatusRuntimeGroup } from './plugin/target-status-runtime-group.ts';
193
208
  import { shouldEmitDedupLog as shouldEmitDedupLogFromRuntime } from './runtime/log-dedupe.ts';
@@ -341,6 +356,8 @@ class BncrBridgeRuntime {
341
356
  // Timers / background workers ---------------------------------------------
342
357
  private saveTimer: NodeJS.Timeout | null = null;
343
358
  private pushTimer: NodeJS.Timeout | null = null;
359
+ private historyShardWorkerTimer: NodeJS.Timeout | null = null;
360
+ private historyShardWorkerRunning = false;
344
361
  private pushDrainRunningAccounts = new Set<string>();
345
362
  private pushDrainRunningSinceByAccount = new Map<string, number>();
346
363
  private pushDrainStuckWarnedAtByAccount = new Map<string, number>();
@@ -837,6 +854,7 @@ class BncrBridgeRuntime {
837
854
  initializeCanonicalAgentId: (cfg) => this.initializeCanonicalAgentId(cfg),
838
855
  logWarn: (scope, message, options) => this.logWarn(scope, message, options),
839
856
  loadState: () => this.loadState(),
857
+ cutoverToSqlite: () => this.stateStore.cutoverToSqlite(),
840
858
  setDebugFlag: (value) => {
841
859
  BNCR_DEBUG_VERBOSE = value;
842
860
  },
@@ -851,6 +869,7 @@ class BncrBridgeRuntime {
851
869
  ctx,
852
870
  debug,
853
871
  );
872
+ this.startHistoryShardWorker();
854
873
  };
855
874
 
856
875
  stopService = async () => {
@@ -868,6 +887,7 @@ class BncrBridgeRuntime {
868
887
  }
869
888
 
870
889
  private cleanupRuntimeWaitersAndTimers(reason: string) {
890
+ this.stopHistoryShardWorker(reason);
871
891
  this.clientRpcRuntime.shutdown();
872
892
  cleanupBncrBridgeRuntime(
873
893
  {
@@ -1078,13 +1098,85 @@ class BncrBridgeRuntime {
1078
1098
  }
1079
1099
 
1080
1100
  private async loadState() {
1081
- await this.stateStore.loadState();
1101
+ // A hot bridge rebuild must not recover shards whose old serial task is
1102
+ // still uploading in this process. Normal process restarts have no active
1103
+ // keys and therefore recover every in-flight shard.
1104
+ const activeHistoryKeys = readConversationHistorySerialHistoryKeys();
1105
+ clearConversationHistorySerialLocks(
1106
+ 'runtime-restart',
1107
+ `${this.bridgeId}:${Date.now()}:${Math.random().toString(16).slice(2, 8)}`,
1108
+ );
1109
+ await this.stateStore.recoverInFlightHistoryShards(activeHistoryKeys);
1110
+ await this.stateStore.loadState(activeHistoryKeys);
1082
1111
  }
1083
1112
 
1084
1113
  private async flushState() {
1085
1114
  await this.stateStore.flushState();
1086
1115
  }
1087
1116
 
1117
+ private startHistoryShardWorker() {
1118
+ if (this.historyShardWorkerTimer) {
1119
+ clearInterval(this.historyShardWorkerTimer);
1120
+ this.historyShardWorkerTimer = null;
1121
+ }
1122
+ if (this.stopped) return;
1123
+ this.historyShardWorkerTimer = setInterval(() => {
1124
+ void this.tickHistoryShardWorker();
1125
+ }, HISTORY_SHARD_WORKER_INTERVAL_MS);
1126
+ this.historyShardWorkerTimer.unref?.();
1127
+ }
1128
+
1129
+ private stopHistoryShardWorker(reason: string) {
1130
+ if (this.historyShardWorkerTimer) {
1131
+ clearInterval(this.historyShardWorkerTimer);
1132
+ this.historyShardWorkerTimer = null;
1133
+ }
1134
+ this.logInfo(
1135
+ 'history-shard',
1136
+ `history-shard worker stopped|bridge=${this.bridgeId}|reason=${reason}`,
1137
+ { debugOnly: true },
1138
+ );
1139
+ }
1140
+
1141
+ private async tickHistoryShardWorker() {
1142
+ if (this.historyShardWorkerRunning || this.stopped) return;
1143
+ this.historyShardWorkerRunning = true;
1144
+ try {
1145
+ for (let i = 0; i < HISTORY_SHARD_WORKER_BATCH_LIMIT; i += 1) {
1146
+ const shard = this.stateStore.historyShardQueue.claimNextHistoryShard();
1147
+ if (!shard) break;
1148
+ await this.processHistoryShard(shard);
1149
+ }
1150
+ } catch (error) {
1151
+ this.logWarn('inbound', `history shard worker tick failed: ${String(error)}`);
1152
+ } finally {
1153
+ this.historyShardWorkerRunning = false;
1154
+ }
1155
+ }
1156
+
1157
+ private async processHistoryShard(shard: BncrHistoryShardRow) {
1158
+ const cfg = getOpenClawRuntimeConfig(this.api);
1159
+ await processBncrHistoryShardSlot({
1160
+ shard,
1161
+ historyShardQueue: this.stateStore.historyShardQueue,
1162
+ conversationHistories: this.conversationHistories,
1163
+ outboundReplayCache: this.outboundReplayCache,
1164
+ runDispatch: (payload: BncrHistoryShardDispatchPayload) =>
1165
+ runBncrInboundReplyDispatch({
1166
+ api: this.api,
1167
+ channelId: CHANNEL_ID,
1168
+ cfg,
1169
+ ...payload,
1170
+ setInboundActivity: (accountId, at) => {
1171
+ this.lastInboundByAccount.set(accountId, at);
1172
+ this.markActivity(accountId, at);
1173
+ },
1174
+ scheduleSave: () => this.scheduleSave(),
1175
+ enqueueFromReply: (args) => this.enqueueFromReply(args),
1176
+ }),
1177
+ });
1178
+ }
1179
+
1088
1180
  private resolveMessageAck(messageId: string, result: 'acked' | 'timeout' = 'acked') {
1089
1181
  return this.messageAckRuntime.resolveMessageAck(messageId, result);
1090
1182
  }
@@ -2175,10 +2267,14 @@ class BncrBridgeRuntime {
2175
2267
  const groupId = entry.route?.groupId || '0';
2176
2268
  const userId = entry.route?.userId || '0';
2177
2269
  const sceneKey = groupId !== '0' ? `${platform}:${groupId}` : `${platform}:${userId}`;
2178
- const sceneLimit = this.sceneRegistry.get(sceneKey)?.historyLimit;
2179
- return typeof sceneLimit === 'number' && Number.isFinite(sceneLimit) && sceneLimit >= 0
2180
- ? Math.floor(sceneLimit)
2181
- : DEFAULT_HISTORY_LIMIT;
2270
+ return resolveBncrHistoryLimit(this.sceneRegistry.get(sceneKey)?.historyLimit);
2271
+ },
2272
+ resolveOutboundHistoryForce: (entry) => {
2273
+ const platform = entry.route?.platform || '';
2274
+ const groupId = entry.route?.groupId || '0';
2275
+ const userId = entry.route?.userId || '0';
2276
+ const sceneKey = groupId !== '0' ? `${platform}:${groupId}` : `${platform}:${userId}`;
2277
+ return this.sceneRegistry.get(sceneKey)?.historyForce !== false;
2182
2278
  },
2183
2279
  resolveOutboundSender: (entry) => {
2184
2280
  try {
@@ -2188,6 +2284,8 @@ class BncrBridgeRuntime {
2188
2284
  return { sender: 'OpenClaw', senderId: entry.accountId };
2189
2285
  }
2190
2286
  },
2287
+ onConversationHistoryOverflow: (entry, historyVersion) =>
2288
+ this.flushConversationHistoryFromOutbound(entry, historyVersion),
2191
2289
  isOutboundAckRequired: (accountId) => this.isOutboundAckRequired(accountId),
2192
2290
  scheduleSave: () => this.scheduleSave(),
2193
2291
  flushPushQueueBestEffort: (args) => this.flushPushQueueBestEffort(args),
@@ -2775,6 +2873,35 @@ class BncrBridgeRuntime {
2775
2873
  this.bridgeOutboxFacade.markRecentOutboundAcked(entry);
2776
2874
  }
2777
2875
 
2876
+ private flushConversationHistoryFromOutbound(entry: OutboxEntry, historyVersion?: number) {
2877
+ const cfg = getOpenClawRuntimeConfig(this.api);
2878
+ const canonicalAgentId = this.canonicalAgentId || 'main';
2879
+ void dispatchBncrOutboundHistoryFlush({
2880
+ api: this.api,
2881
+ channelId: CHANNEL_ID,
2882
+ cfg,
2883
+ entry,
2884
+ ...(historyVersion !== undefined ? { historyVersion } : {}),
2885
+ canonicalAgentId,
2886
+ sceneRegistry: this.sceneRegistry,
2887
+ conversationHistories: this.conversationHistories,
2888
+ outboundReplayCache: this.outboundReplayCache,
2889
+ defaultAdminAgentId: canonicalAgentId,
2890
+ defaultPublicAgentId: this.defaultPublicAgentId(),
2891
+ now,
2892
+ rememberSessionRoute: (sessionKey, accountId, route) =>
2893
+ this.rememberSessionRoute(sessionKey, accountId, route),
2894
+ enqueueFromReply: (args) => this.enqueueFromReply(args),
2895
+ setInboundActivity: (accountId, at) => {
2896
+ this.lastInboundByAccount.set(accountId, at);
2897
+ this.markActivity(accountId, at);
2898
+ },
2899
+ scheduleSave: () => this.scheduleSave(),
2900
+ }).catch((err) => {
2901
+ this.logWarn('inbound', `outbound history flush failed: ${String(err)}`);
2902
+ });
2903
+ }
2904
+
2778
2905
  private logEnqueueFromReply(args: {
2779
2906
  accountId: string;
2780
2907
  sessionKey: string;
@@ -224,7 +224,10 @@ export async function handleBncrNativeCommand(params: {
224
224
  });
225
225
 
226
226
  const nativeVerbose = resolveBncrNativeVerboseCommand(command);
227
- const nativeHelp = resolveBncrNativeHelpCommand(command);
227
+ const nativeHelp = resolveBncrNativeHelpCommand(command, {
228
+ isAdmin: parsed.isAdmin,
229
+ peerKind: parsed.peer.kind as 'direct' | 'group',
230
+ });
228
231
  const nativeWhoami = resolveBncrNativeWhoamiCommand({
229
232
  command,
230
233
  platform: parsed.platform,
@@ -244,7 +247,10 @@ export async function handleBncrNativeCommand(params: {
244
247
  resolvedAgentId: resolvedRoute.agentId || canonicalAgentId,
245
248
  sessionKey,
246
249
  });
247
- const nativeSessionReset = resolveBncrNativeSessionResetCommand({ command });
250
+ const nativeSessionReset = resolveBncrNativeSessionResetCommand({
251
+ command,
252
+ peerKind: parsed.peer.kind as 'direct' | 'group',
253
+ });
248
254
  if (nativeHelp) {
249
255
  logBncrNativeCommandSummary(
250
256
  buildBncrNativeCommandSummary({
@@ -336,7 +342,7 @@ export async function handleBncrNativeCommand(params: {
336
342
  }
337
343
 
338
344
  if (nativeSessionReset) {
339
- const allowLocalSessionReset = parsed.peer.kind === 'direct';
345
+ const allowLocalSessionReset = parsed.peer.kind === 'direct' || parsed.isAdmin;
340
346
  if (!allowLocalSessionReset) {
341
347
  logBncrNativeCommandSummary(
342
348
  buildBncrNativeCommandSummary({
@@ -481,8 +487,7 @@ export async function handleBncrNativeCommand(params: {
481
487
 
482
488
  const sceneAdmin = parseSceneAdminCommand(command);
483
489
  if (sceneAdmin.matched) {
484
- const silentNonAdminGroupReject = !parsed.isAdmin && parsed.peer.kind === 'group';
485
- if (!sceneAdmin.valid) {
490
+ if (!parsed.isAdmin) {
486
491
  logBncrNativeCommandSummary(
487
492
  buildBncrNativeCommandSummary({
488
493
  kind: 'scene-admin',
@@ -493,17 +498,50 @@ export async function handleBncrNativeCommand(params: {
493
498
  result: 'rejected',
494
499
  }),
495
500
  );
496
- if (!silentNonAdminGroupReject) {
497
- await enqueueFromReply({
501
+ await recordAndPatchBncrInboundSessionEntry({
502
+ storePath,
503
+ sessionKey,
504
+ ctx: ctxPayload,
505
+ patch: sessionIdentityPatch,
506
+ });
507
+ await enqueueFromReply({
508
+ accountId,
509
+ sessionKey,
510
+ route,
511
+ payload: {
512
+ text: 'Admin permission required.',
513
+ replyToId: msgId || undefined,
514
+ },
515
+ });
516
+ return { handled: true, command: command.command, sessionKey };
517
+ }
518
+
519
+ if (!sceneAdmin.valid) {
520
+ logBncrNativeCommandSummary(
521
+ buildBncrNativeCommandSummary({
522
+ kind: 'scene-admin',
523
+ command: command.command,
498
524
  accountId,
499
- sessionKey,
500
- route,
501
- payload: {
502
- text: sceneAdmin.text,
503
- replyToId: msgId || undefined,
504
- },
505
- });
506
- }
525
+ to: displayTo,
526
+ msgId: msgId ?? null,
527
+ result: 'rejected',
528
+ }),
529
+ );
530
+ await recordAndPatchBncrInboundSessionEntry({
531
+ storePath,
532
+ sessionKey,
533
+ ctx: ctxPayload,
534
+ patch: sessionIdentityPatch,
535
+ });
536
+ await enqueueFromReply({
537
+ accountId,
538
+ sessionKey,
539
+ route,
540
+ payload: {
541
+ text: sceneAdmin.text,
542
+ replyToId: msgId || undefined,
543
+ },
544
+ });
507
545
  return { handled: true, command: command.command, sessionKey };
508
546
  }
509
547
 
@@ -531,17 +569,15 @@ export async function handleBncrNativeCommand(params: {
531
569
  ctx: ctxPayload,
532
570
  patch: sessionIdentityPatch,
533
571
  });
534
- if (!(silentNonAdminGroupReject && !outcome.ok)) {
535
- await enqueueFromReply({
536
- accountId,
537
- sessionKey,
538
- route,
539
- payload: {
540
- text: outcome.text,
541
- replyToId: msgId || undefined,
542
- },
543
- });
544
- }
572
+ await enqueueFromReply({
573
+ accountId,
574
+ sessionKey,
575
+ route,
576
+ payload: {
577
+ text: outcome.text,
578
+ replyToId: msgId || undefined,
579
+ },
580
+ });
545
581
  return { handled: true, command: command.command, sessionKey };
546
582
  }
547
583