@xmoxmo/bncr 0.6.6 → 0.6.8

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/src/channel.ts CHANGED
@@ -348,6 +348,7 @@ class BncrBridgeRuntime {
348
348
  // 内置健康/回归计数(替代独立脚本)
349
349
  private startedAt = now();
350
350
  private stopped = false;
351
+ private serviceStarted = false;
351
352
  private connectEventsByAccount = new Map<string, number>();
352
353
  private inboundEventsByAccount = new Map<string, number>();
353
354
  private activityEventsByAccount = new Map<string, number>();
@@ -840,8 +841,21 @@ class BncrBridgeRuntime {
840
841
  return BNCR_DEBUG_VERBOSE;
841
842
  }
842
843
 
843
- startService = async (ctx: OpenClawPluginServiceContext, debug?: boolean) => {
844
- await startBncrBridgeService(
844
+ getRuntimeObservation() {
845
+ const activeChannelAccounts = this.channelAccountWorkers.size;
846
+ return {
847
+ serviceRunning: this.serviceStarted,
848
+ channelRunning: activeChannelAccounts > 0,
849
+ activeChannelAccounts,
850
+ };
851
+ }
852
+
853
+ startService = async (
854
+ ctx: OpenClawPluginServiceContext,
855
+ debug?: boolean,
856
+ isStartCurrent?: () => boolean,
857
+ ) => {
858
+ const started = await startBncrBridgeService(
845
859
  {
846
860
  bridgeId: this.bridgeId,
847
861
  setStopped: (value) => {
@@ -865,11 +879,19 @@ class BncrBridgeRuntime {
865
879
  (((cfg as BncrChannelConfigRoot | null | undefined)?.channels || null)?.[CHANNEL_ID] as
866
880
  | Record<string, unknown>
867
881
  | undefined) || {},
882
+ resumeOutboxDrain: () => {
883
+ if (this.outbox.size === 0 || this.connections.size === 0) return;
884
+ this.schedulePushDrain(0);
885
+ },
886
+ isStartCurrent,
868
887
  },
869
888
  ctx,
870
889
  debug,
871
890
  );
891
+ if (!started || this.stopped || !(isStartCurrent?.() ?? true)) return false;
872
892
  this.startHistoryShardWorker();
893
+ this.serviceStarted = true;
894
+ return true;
873
895
  };
874
896
 
875
897
  stopService = async () => {
@@ -887,6 +909,7 @@ class BncrBridgeRuntime {
887
909
  }
888
910
 
889
911
  private cleanupRuntimeWaitersAndTimers(reason: string) {
912
+ this.serviceStarted = false;
890
913
  this.stopHistoryShardWorker(reason);
891
914
  this.clientRpcRuntime.shutdown();
892
915
  cleanupBncrBridgeRuntime(
@@ -11,6 +11,7 @@ import {
11
11
  import { dispatchOpenClawReplyWithBufferedBlockDispatcher } from '../../openclaw/reply-runtime.ts';
12
12
  import { resolveOpenClawAgentRoute } from '../../openclaw/routing-runtime.ts';
13
13
  import { performBncrGatewaySessionReset } from '../../openclaw/session-reset-runtime.ts';
14
+ import { addNativeElevation, removeNativeElevation } from '../../plugin/config.ts';
14
15
  import { mergeBncrOwnerAllowFromIntoConfig } from './command-owner.ts';
15
16
  import type {
16
17
  BncrEnqueueFromReply,
@@ -774,74 +775,83 @@ export async function handleBncrNativeCommand(params: {
774
775
  },
775
776
  { debugOnly: true, debugEnabled: nativeCommandDebugEnabled },
776
777
  );
777
- await resolveBncrChannelInboundRuntime(api).run({
778
- channel: channelId,
779
- accountId,
780
- raw: parsed,
781
- adapter: {
782
- ingest: () => ({
783
- id: msgId ?? `${displayTo}:${Date.now()}`,
784
- timestamp: Date.now(),
785
- rawText: body,
786
- textForAgent: ctxPayload.BodyForAgent,
787
- textForCommands: ctxPayload.CommandBody,
788
- raw: parsed,
789
- }),
790
- resolveTurn: () => {
791
- const sessionMetaBarrier = createBncrSessionMetaTaskBarrier();
792
- return {
793
- channel: channelId,
794
- accountId,
795
- routeSessionKey: resolvedRoute.sessionKey,
796
- storePath,
797
- ctxPayload,
798
- recordInboundSession: wrapBncrInboundRecordSessionLabelCorrection({
799
- recordInboundSession: recordBncrInboundSession as (
800
- ...args: unknown[]
801
- ) => Promise<unknown> | unknown,
802
- expectedPatch: sessionIdentityPatch,
803
- }),
804
- record: {
805
- updateLastRoute,
806
- onRecordError: (err: unknown) => {
807
- buildNativeCommandRecordErrorLogger(err);
808
- },
809
- trackSessionMetaTask: (task: Promise<unknown>) => {
810
- sessionMetaBarrier.track(task);
811
- },
812
- },
813
- runDispatch: async () => {
814
- await sessionMetaBarrier.wait();
815
- return dispatchOpenClawReplyWithBufferedBlockDispatcher(api, {
816
- ctx: ctxPayload,
817
- cfg: effectiveReply.replyCfg,
818
- dispatcherOptions: {
819
- deliver: createNativeCommandReplyDeliverer({
820
- command: command.command,
821
- accountId,
822
- sessionKey,
823
- to: displayTo,
824
- msgId: msgId || undefined,
825
- effectiveReply,
826
- route,
827
- enqueueFromReply,
828
- nativeCommandDebugEnabled,
829
- onResponded: () => responded,
830
- markResponded: () => {
831
- responded = true;
832
- },
833
- }),
778
+ if (dispatchToOpenClaw && senderIdForContext) {
779
+ addNativeElevation(senderIdForContext);
780
+ }
781
+ try {
782
+ await resolveBncrChannelInboundRuntime(api).run({
783
+ channel: channelId,
784
+ accountId,
785
+ raw: parsed,
786
+ adapter: {
787
+ ingest: () => ({
788
+ id: msgId ?? `${displayTo}:${Date.now()}`,
789
+ timestamp: Date.now(),
790
+ rawText: body,
791
+ textForAgent: ctxPayload.BodyForAgent,
792
+ textForCommands: ctxPayload.CommandBody,
793
+ raw: parsed,
794
+ }),
795
+ resolveTurn: () => {
796
+ const sessionMetaBarrier = createBncrSessionMetaTaskBarrier();
797
+ return {
798
+ channel: channelId,
799
+ accountId,
800
+ routeSessionKey: resolvedRoute.sessionKey,
801
+ storePath,
802
+ ctxPayload,
803
+ recordInboundSession: wrapBncrInboundRecordSessionLabelCorrection({
804
+ recordInboundSession: recordBncrInboundSession as (
805
+ ...args: unknown[]
806
+ ) => Promise<unknown> | unknown,
807
+ expectedPatch: sessionIdentityPatch,
808
+ }),
809
+ record: {
810
+ updateLastRoute,
811
+ onRecordError: (err: unknown) => {
812
+ buildNativeCommandRecordErrorLogger(err);
834
813
  },
835
- replyOptions: {
836
- disableBlockStreaming: !effectiveReply.blockStreaming,
837
- shouldEmitToolResult: effectiveReply.allowTool ? () => true : undefined,
814
+ trackSessionMetaTask: (task: Promise<unknown>) => {
815
+ sessionMetaBarrier.track(task);
838
816
  },
839
- });
840
- },
841
- };
817
+ },
818
+ runDispatch: async () => {
819
+ await sessionMetaBarrier.wait();
820
+ return dispatchOpenClawReplyWithBufferedBlockDispatcher(api, {
821
+ ctx: ctxPayload,
822
+ cfg: effectiveReply.replyCfg,
823
+ dispatcherOptions: {
824
+ deliver: createNativeCommandReplyDeliverer({
825
+ command: command.command,
826
+ accountId,
827
+ sessionKey,
828
+ to: displayTo,
829
+ msgId: msgId || undefined,
830
+ effectiveReply,
831
+ route,
832
+ enqueueFromReply,
833
+ nativeCommandDebugEnabled,
834
+ onResponded: () => responded,
835
+ markResponded: () => {
836
+ responded = true;
837
+ },
838
+ }),
839
+ },
840
+ replyOptions: {
841
+ disableBlockStreaming: !effectiveReply.blockStreaming,
842
+ shouldEmitToolResult: effectiveReply.allowTool ? () => true : undefined,
843
+ },
844
+ });
845
+ },
846
+ };
847
+ },
842
848
  },
843
- },
844
- });
849
+ });
850
+ } finally {
851
+ if (dispatchToOpenClaw && senderIdForContext && !parsed.isAdmin) {
852
+ removeNativeElevation(senderIdForContext);
853
+ }
854
+ }
845
855
 
846
856
  if (!responded) {
847
857
  // Self-service commands must never fall back to the agent — doing so
@@ -5,7 +5,7 @@ import {
5
5
  type HistoryEntry,
6
6
  type HistoryMediaEntry,
7
7
  } from 'openclaw/plugin-sdk/reply-history';
8
- import { normalizeAccountId } from '../../core/accounts.ts';
8
+ import { BNCR_DEFAULT_ACCOUNT_ID, normalizeAccountId } from '../../core/accounts.ts';
9
9
  import { formatOpenClawAgentEnvelope } from '../../openclaw/reply-runtime.ts';
10
10
  import type { BncrInboundApi } from './contracts.ts';
11
11
  import type { ParsedInbound } from './dispatch-prep.ts';
@@ -121,6 +121,33 @@ export function buildBncrConversationHistoryKeyFromRoute(args: {
121
121
  return null;
122
122
  }
123
123
 
124
+ /**
125
+ * Migrate legacy scene-only history keys (`tgBot:10001`) to the current
126
+ * account-scoped format (`Primary:tgBot:10001`). Keys that already carry an
127
+ * accountId prefix are returned unchanged.
128
+ */
129
+ export function normalizePersistedHistoryKey(key: string): string {
130
+ const raw = String(key || '').trim();
131
+ if (!raw) return raw;
132
+ const firstColon = raw.indexOf(':');
133
+ const secondColon = raw.indexOf(':', firstColon + 1);
134
+ if (firstColon <= 0 || secondColon < 0) return `${BNCR_DEFAULT_ACCOUNT_ID}:${raw}`;
135
+ return `${normalizeAccountId(raw.slice(0, firstColon))}:${raw.slice(firstColon + 1)}`;
136
+ }
137
+
138
+ export function resolvePersistedHistoryAccountId(key: string): string {
139
+ const normalized = normalizePersistedHistoryKey(key);
140
+ const separator = normalized.indexOf(':');
141
+ return normalizeAccountId(separator > 0 ? normalized.slice(0, separator) : undefined);
142
+ }
143
+
144
+ export function resolvePersistedHistoryKey(key: string, accountId: string): string {
145
+ const normalized = normalizePersistedHistoryKey(key);
146
+ const separator = normalized.indexOf(':');
147
+ const sceneKey = separator > 0 ? normalized.slice(separator + 1) : normalized;
148
+ return `${normalizeAccountId(accountId)}:${sceneKey}`;
149
+ }
150
+
124
151
  /**
125
152
  * Extract the scene key (platform:userId or platform:groupId) from a history
126
153
  * key that may contain an accountId prefix. Legacy scene-only keys pass
@@ -59,13 +59,15 @@ export function startBncrBridgeService(
59
59
  getChannelConfigRoot: (
60
60
  cfg: BncrChannelConfigRoot,
61
61
  ) => BncrChannelPolicyConfig | null | undefined;
62
+ resumeOutboxDrain?: () => void;
63
+ isStartCurrent?: () => boolean;
62
64
  },
63
65
  ctx: OpenClawPluginServiceContext,
64
66
  debug?: boolean,
65
67
  ) {
66
- runtime.setStopped(false);
67
68
  runtime.setStatePath(path.join(ctx.stateDir, 'bncr-bridge-state.json'));
68
69
  return (async () => {
70
+ const isStartCurrent = () => runtime.isStartCurrent?.() ?? true;
69
71
  try {
70
72
  const cfg = runtime.getRuntimeConfig();
71
73
  runtime.initializeCanonicalAgentId(cfg);
@@ -76,8 +78,10 @@ export function startBncrBridgeService(
76
78
  // ignore startup canonical agent initialization errors
77
79
  }
78
80
  await runtime.loadState();
81
+ if (!isStartCurrent()) return false;
79
82
  if (process.env.BNCR_SQLITE_CUTOVER === '1' && runtime.cutoverToSqlite) {
80
83
  const cutover = await runtime.cutoverToSqlite();
84
+ if (!isStartCurrent()) return false;
81
85
  runtime.logInfo(
82
86
  'sqlite',
83
87
  `cutover completed backup=${cutover.backupPath || 'none'} storeMode=${cutover.storeMode}`,
@@ -85,6 +89,8 @@ export function startBncrBridgeService(
85
89
  }
86
90
  if (typeof debug === 'boolean') runtime.setDebugFlag(debug);
87
91
  await runtime.refreshDebugFlagFromConfig({ forceLog: true });
92
+ if (!isStartCurrent()) return false;
93
+ runtime.setStopped(false);
88
94
  const bootDiag = runtime.buildIntegratedDiagnostics(BNCR_DEFAULT_ACCOUNT_ID);
89
95
  runtime.logInfo(
90
96
  'startup',
@@ -95,7 +101,12 @@ export function startBncrBridgeService(
95
101
  `service started bridge=${runtime.bridgeId} diag.ok=${bootDiag.regression.ok} routes=${bootDiag.regression.totalKnownRoutes} pending=${bootDiag.health.pending} dead=${bootDiag.health.deadLetter}`,
96
102
  { debugOnly: true },
97
103
  );
98
- })();
104
+ runtime.resumeOutboxDrain?.();
105
+ return true;
106
+ })().catch((error) => {
107
+ runtime.setStopped(true);
108
+ throw error;
109
+ });
99
110
  }
100
111
 
101
112
  export function cleanupBncrBridgeRuntime(
@@ -7,6 +7,17 @@ import {
7
7
  import { resolveBncrChannelPolicy } from '../core/policy.ts';
8
8
  import { setOpenClawAccountEnabledInConfigSection } from '../openclaw/sdk-helpers.ts';
9
9
 
10
+ // 临时提权集合:非管理员用户执行原生命令时动态注入,命令完成后移除
11
+ const pendingNativeElevation = new Set<string>();
12
+
13
+ export function addNativeElevation(senderId: string): void {
14
+ if (senderId) pendingNativeElevation.add(senderId);
15
+ }
16
+
17
+ export function removeNativeElevation(senderId: string): void {
18
+ if (senderId) pendingNativeElevation.delete(senderId);
19
+ }
20
+
10
21
  type BncrConfigSurfaceRoot = {
11
22
  channels?: {
12
23
  [CHANNEL_ID]?: {
@@ -53,4 +64,11 @@ export const BNCR_CONFIG_SURFACE = {
53
64
  configured: true,
54
65
  };
55
66
  },
67
+ resolveAllowFrom: ({ cfg }: { cfg: BncrConfigSurfaceRoot; accountId?: string | null }) => {
68
+ const policy = resolveBncrChannelPolicy(cfg?.channels?.[CHANNEL_ID] || {});
69
+ const base = policy.allowFrom;
70
+ if (pendingNativeElevation.size === 0) return base.length ? base : undefined;
71
+ const merged = [...base, ...pendingNativeElevation];
72
+ return merged.length ? merged : undefined;
73
+ },
56
74
  };
@@ -1,4 +1,5 @@
1
1
  import type { GatewayRequestHandlerOptions } from 'openclaw/plugin-sdk/core';
2
+ import { runDetachedWebhookWork } from 'openclaw/plugin-sdk/webhook-request-guards';
2
3
  import { buildCanonicalBncrSessionKey } from '../core/targets.ts';
3
4
  import type { BncrConnection, BncrRoute } from '../core/types.ts';
4
5
  import type { BncrConversationHistoryMap } from '../messaging/inbound/conversation-history.ts';
@@ -171,6 +172,7 @@ export type BncrInboundHandlersRuntime = {
171
172
  defaultPublicAgentId: string;
172
173
  now: () => number;
173
174
  }) => Promise<unknown>;
175
+ runInboundDetached?: (run: () => Promise<unknown>) => Promise<unknown>;
174
176
  };
175
177
 
176
178
  export function createBncrInboundHandlers(runtime: Omit<BncrInboundHandlersRuntime, 'respond'>) {
@@ -326,8 +328,12 @@ export function createBncrInboundHandlers(runtime: Omit<BncrInboundHandlersRunti
326
328
  );
327
329
  runtime.flushOnInboundAccepted(accountId);
328
330
 
329
- void runtime
330
- .dispatchInbound({
331
+ const runInboundDetached = runtime.runInboundDetached ?? runDetachedWebhookWork;
332
+ // Ack-first handlers must run follow-up work on an independent admitted
333
+ // root. Otherwise the host releases this request admission after respond,
334
+ // and later native/agent queue submissions are mistaken for draining.
335
+ void runInboundDetached(async () => {
336
+ await runtime.dispatchInbound({
331
337
  cfg,
332
338
  parsed,
333
339
  canonicalAgentId,
@@ -340,10 +346,10 @@ export function createBncrInboundHandlers(runtime: Omit<BncrInboundHandlersRunti
340
346
  defaultAdminAgentId,
341
347
  defaultPublicAgentId: runtime.defaultPublicAgentId(),
342
348
  now: runtime.now,
343
- })
344
- .catch((err) => {
345
- runtime.logError('inbound', `process failed: ${String(err)}`, { debugOnly: true });
346
349
  });
350
+ }).catch((err) => {
351
+ runtime.logError('inbound', `process failed: ${String(err)}`, { debugOnly: true });
352
+ });
347
353
  },
348
354
  };
349
355
  }
@@ -1,12 +1,17 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { DatabaseSync } from 'node:sqlite';
3
- import { BNCR_DEFAULT_ACCOUNT_ID, normalizeAccountId } from '../core/accounts.ts';
3
+ import { normalizeAccountId } from '../core/accounts.ts';
4
4
  import {
5
5
  dumpRegisterDriftSnapshot,
6
6
  normalizeRegisterDriftSnapshot,
7
7
  type RegisterDriftSnapshot,
8
8
  } from '../core/register-trace.ts';
9
9
  import type { BncrRoute, OutboxEntry } from '../core/types.ts';
10
+ import {
11
+ normalizePersistedHistoryKey,
12
+ resolvePersistedHistoryAccountId,
13
+ resolvePersistedHistoryKey,
14
+ } from '../messaging/inbound/conversation-history.ts';
10
15
  import type {
11
16
  BncrGroupReplyMode,
12
17
  BncrPersistedAccountTimestamp,
@@ -792,28 +797,6 @@ function deriveHistoryKeyFromRoute(args: {
792
797
  return null;
793
798
  }
794
799
 
795
- function normalizePersistedHistoryKey(key: string): string {
796
- const raw = String(key || '').trim();
797
- if (!raw) return raw;
798
- const firstColon = raw.indexOf(':');
799
- const secondColon = raw.indexOf(':', firstColon + 1);
800
- if (firstColon <= 0 || secondColon < 0) return `${BNCR_DEFAULT_ACCOUNT_ID}:${raw}`;
801
- return `${normalizeAccountId(raw.slice(0, firstColon))}:${raw.slice(firstColon + 1)}`;
802
- }
803
-
804
- function resolvePersistedHistoryAccountId(key: string): string {
805
- const normalized = normalizePersistedHistoryKey(key);
806
- const separator = normalized.indexOf(':');
807
- return normalizeAccountId(separator > 0 ? normalized.slice(0, separator) : undefined);
808
- }
809
-
810
- function resolvePersistedHistoryKey(key: string, accountId: string): string {
811
- const normalized = normalizePersistedHistoryKey(key);
812
- const separator = normalized.indexOf(':');
813
- const sceneKey = separator > 0 ? normalized.slice(separator + 1) : normalized;
814
- return `${normalizeAccountId(accountId)}:${sceneKey}`;
815
- }
816
-
817
800
  function parseMediaJson(value: string) {
818
801
  try {
819
802
  const parsed = JSON.parse(value ?? '[]');
@@ -1,7 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { readFile, rename } from 'node:fs/promises';
3
3
  import { basename, dirname, join } from 'node:path';
4
- import { BNCR_DEFAULT_ACCOUNT_ID, normalizeAccountId } from '../core/accounts.ts';
5
4
  import {
6
5
  dumpRegisterDriftSnapshot,
7
6
  normalizeRegisterDriftSnapshot,
@@ -11,9 +10,12 @@ import {
11
10
  type BncrConversationHistoryMap,
12
11
  buildBncrBotReplyMessageId,
13
12
  buildBncrConversationHistoryKeyFromRoute,
13
+ normalizePersistedHistoryKey,
14
14
  resetConversationHistoryVersions,
15
15
  resolveBncrHistoryLimit,
16
16
  resolveBncrSceneKeyFromHistoryKey,
17
+ resolvePersistedHistoryAccountId,
18
+ resolvePersistedHistoryKey,
17
19
  } from '../messaging/inbound/conversation-history.ts';
18
20
  import {
19
21
  getConversationHistorySerialOwner,
@@ -53,35 +55,6 @@ import type {
53
55
  const GROUP_REPLY_MODES = new Set<BncrGroupReplyMode>(['admin', 'mention', 'hybrid', 'all']);
54
56
  const DEFAULT_PERSISTED_CONVERSATION_HISTORY_LIMIT = 50;
55
57
 
56
- /**
57
- * Migrate legacy scene-only history keys (`tgBot:10001`) to the current
58
- * account-scoped format (`Primary:tgBot:10001`). Keys that already carry an
59
- * accountId prefix are returned unchanged.
60
- */
61
- function normalizePersistedHistoryKey(key: string): string {
62
- const raw = String(key || '').trim();
63
- if (!raw) return raw;
64
- const firstColon = raw.indexOf(':');
65
- const secondColon = raw.indexOf(':', firstColon + 1);
66
- if (firstColon <= 0 || secondColon < 0) return `${BNCR_DEFAULT_ACCOUNT_ID}:${raw}`;
67
- return `${normalizeAccountId(raw.slice(0, firstColon))}:${raw.slice(firstColon + 1)}`;
68
- }
69
-
70
- function resolvePersistedHistoryAccountId(key: string): string {
71
- const normalized = normalizePersistedHistoryKey(key);
72
- const separator = normalized.indexOf(':');
73
- return separator > 0
74
- ? normalized.slice(0, separator).trim() || BNCR_DEFAULT_ACCOUNT_ID
75
- : BNCR_DEFAULT_ACCOUNT_ID;
76
- }
77
-
78
- function resolvePersistedHistoryKey(key: string, accountId: string): string {
79
- const normalized = normalizePersistedHistoryKey(key);
80
- const separator = normalized.indexOf(':');
81
- const sceneKey = separator > 0 ? normalized.slice(separator + 1) : normalized;
82
- return `${normalizeAccountId(accountId)}:${sceneKey}`;
83
- }
84
-
85
58
  type BncrPersistedStateStoreInput = {
86
59
  outbox?: unknown;
87
60
  deadLetter?: unknown;