@xmoxmo/bncr 0.6.5 → 0.6.7
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 +146 -338
- package/package.json +7 -7
- package/scripts/check-pack.mjs +5 -1
- package/scripts/selfcheck.mjs +2 -1
- package/src/core/types.ts +1 -1
- package/src/messaging/inbound/commands.ts +118 -67
- package/src/messaging/inbound/conversation-history.ts +53 -5
- package/src/messaging/inbound/native-command.ts +7 -11
- package/src/messaging/inbound/outbound-replay-cache.ts +18 -2
- package/src/messaging/inbound/reply-dispatch.ts +8 -0
- package/src/openclaw/session-reset-runtime.ts +80 -32
- package/src/plugin/channel-runtime-types.ts +1 -1
- package/src/plugin/config.ts +18 -0
- package/src/plugin/inbound-handlers.ts +11 -5
- package/src/plugin/sqlite-state.ts +42 -18
- package/src/plugin/state-store.ts +54 -20
|
@@ -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,
|
|
@@ -120,8 +121,49 @@ export async function handleBncrNativeCommand(params: {
|
|
|
120
121
|
|
|
121
122
|
// Stop always goes through the unified stop fast path in dispatch.ts.
|
|
122
123
|
// It is not a bncr native command and must never be processed here.
|
|
123
|
-
|
|
124
|
-
|
|
124
|
+
// Only exact `/stop` is accepted; any other `/stop*` form is rejected.
|
|
125
|
+
const rawStopText = String(extracted.text || '')
|
|
126
|
+
.trim()
|
|
127
|
+
.toLowerCase();
|
|
128
|
+
if (rawStopText.startsWith('/stop')) {
|
|
129
|
+
if (isBncrStopCommandText(extracted.text)) {
|
|
130
|
+
return { handled: false };
|
|
131
|
+
}
|
|
132
|
+
// Non-exact `/stop` forms (extra args, @bot suffix, or other extension)
|
|
133
|
+
// are rejected immediately so they never reach the agent.
|
|
134
|
+
const rejectedRoute = buildBncrNativeCommandResolvedRoute({
|
|
135
|
+
api,
|
|
136
|
+
cfg,
|
|
137
|
+
channelId,
|
|
138
|
+
accountId,
|
|
139
|
+
peer,
|
|
140
|
+
resolvedAgentId,
|
|
141
|
+
});
|
|
142
|
+
const { baseSessionKey, sessionKey } = buildBncrNativeCommandSessionState({
|
|
143
|
+
parsed,
|
|
144
|
+
sessionAgentId: rejectedRoute.agentId || canonicalAgentId,
|
|
145
|
+
resolvedRoute: rejectedRoute,
|
|
146
|
+
});
|
|
147
|
+
logBncrNativeCommandSummary(
|
|
148
|
+
buildBncrNativeCommandSummary({
|
|
149
|
+
kind: 'stop',
|
|
150
|
+
command: rawStopText,
|
|
151
|
+
accountId,
|
|
152
|
+
to: formatDisplayScope(route),
|
|
153
|
+
msgId: msgId || null,
|
|
154
|
+
result: 'rejected',
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
await enqueueFromReply({
|
|
158
|
+
accountId,
|
|
159
|
+
sessionKey: baseSessionKey,
|
|
160
|
+
route,
|
|
161
|
+
payload: {
|
|
162
|
+
text: 'Only exact /stop is supported. Send /stop to stop.',
|
|
163
|
+
replyToId: msgId || undefined,
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
return { handled: true, command: 'stop', sessionKey };
|
|
125
167
|
}
|
|
126
168
|
|
|
127
169
|
const whitelistBareCommand =
|
|
@@ -733,74 +775,83 @@ export async function handleBncrNativeCommand(params: {
|
|
|
733
775
|
},
|
|
734
776
|
{ debugOnly: true, debugEnabled: nativeCommandDebugEnabled },
|
|
735
777
|
);
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
},
|
|
772
|
-
runDispatch: async () => {
|
|
773
|
-
await sessionMetaBarrier.wait();
|
|
774
|
-
return dispatchOpenClawReplyWithBufferedBlockDispatcher(api, {
|
|
775
|
-
ctx: ctxPayload,
|
|
776
|
-
cfg: effectiveReply.replyCfg,
|
|
777
|
-
dispatcherOptions: {
|
|
778
|
-
deliver: createNativeCommandReplyDeliverer({
|
|
779
|
-
command: command.command,
|
|
780
|
-
accountId,
|
|
781
|
-
sessionKey,
|
|
782
|
-
to: displayTo,
|
|
783
|
-
msgId: msgId || undefined,
|
|
784
|
-
effectiveReply,
|
|
785
|
-
route,
|
|
786
|
-
enqueueFromReply,
|
|
787
|
-
nativeCommandDebugEnabled,
|
|
788
|
-
onResponded: () => responded,
|
|
789
|
-
markResponded: () => {
|
|
790
|
-
responded = true;
|
|
791
|
-
},
|
|
792
|
-
}),
|
|
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);
|
|
793
813
|
},
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
shouldEmitToolResult: effectiveReply.allowTool ? () => true : undefined,
|
|
814
|
+
trackSessionMetaTask: (task: Promise<unknown>) => {
|
|
815
|
+
sessionMetaBarrier.track(task);
|
|
797
816
|
},
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
|
|
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
|
+
},
|
|
801
848
|
},
|
|
802
|
-
}
|
|
803
|
-
}
|
|
849
|
+
});
|
|
850
|
+
} finally {
|
|
851
|
+
if (dispatchToOpenClaw && senderIdForContext && !parsed.isAdmin) {
|
|
852
|
+
removeNativeElevation(senderIdForContext);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
804
855
|
|
|
805
856
|
if (!responded) {
|
|
806
857
|
// Self-service commands must never fall back to the agent — doing so
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type HistoryEntry,
|
|
6
6
|
type HistoryMediaEntry,
|
|
7
7
|
} from 'openclaw/plugin-sdk/reply-history';
|
|
8
|
+
import { BNCR_DEFAULT_ACCOUNT_ID, normalizeAccountId } from '../../core/accounts.ts';
|
|
8
9
|
import { formatOpenClawAgentEnvelope } from '../../openclaw/reply-runtime.ts';
|
|
9
10
|
import type { BncrInboundApi } from './contracts.ts';
|
|
10
11
|
import type { ParsedInbound } from './dispatch-prep.ts';
|
|
@@ -87,33 +88,80 @@ export function resolveBncrHistoryLimit(historyLimit?: number): number {
|
|
|
87
88
|
return resolved >= 2 ? resolved : DEFAULT_HISTORY_LIMIT;
|
|
88
89
|
}
|
|
89
90
|
|
|
90
|
-
export function buildBncrConversationHistoryKey(
|
|
91
|
+
export function buildBncrConversationHistoryKey(
|
|
92
|
+
parsed: ParsedInbound,
|
|
93
|
+
options?: { accountId?: string | null },
|
|
94
|
+
): string | null {
|
|
91
95
|
const platform = String(parsed.platform || '').trim();
|
|
92
96
|
if (!platform) return null;
|
|
97
|
+
const accountId = normalizeAccountId(options?.accountId ?? parsed.accountId);
|
|
93
98
|
if (parsed.peer.kind === 'group') {
|
|
94
99
|
const groupId = String(parsed.groupId || '').trim();
|
|
95
100
|
if (!groupId || groupId === '0') return null;
|
|
96
|
-
return `${platform}:${groupId}`;
|
|
101
|
+
return `${accountId}:${platform}:${groupId}`;
|
|
97
102
|
}
|
|
98
103
|
const userId = String(parsed.userId || '').trim();
|
|
99
104
|
if (!userId || userId === '0') return null;
|
|
100
|
-
return `${platform}:${userId}`;
|
|
105
|
+
return `${accountId}:${platform}:${userId}`;
|
|
101
106
|
}
|
|
102
107
|
|
|
103
108
|
export function buildBncrConversationHistoryKeyFromRoute(args: {
|
|
109
|
+
accountId?: string | null;
|
|
104
110
|
platform?: string;
|
|
105
111
|
groupId?: string;
|
|
106
112
|
userId?: string;
|
|
107
113
|
}): string | null {
|
|
108
114
|
const platform = String(args.platform || '').trim();
|
|
109
115
|
if (!platform) return null;
|
|
116
|
+
const accountId = normalizeAccountId(args.accountId);
|
|
110
117
|
const groupId = String(args.groupId || '0').trim() || '0';
|
|
111
118
|
const userId = String(args.userId || '0').trim() || '0';
|
|
112
|
-
if (groupId !== '0') return `${platform}:${groupId}`;
|
|
113
|
-
if (userId !== '0') return `${platform}:${userId}`;
|
|
119
|
+
if (groupId !== '0') return `${accountId}:${platform}:${groupId}`;
|
|
120
|
+
if (userId !== '0') return `${accountId}:${platform}:${userId}`;
|
|
114
121
|
return null;
|
|
115
122
|
}
|
|
116
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
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Extract the scene key (platform:userId or platform:groupId) from a history
|
|
153
|
+
* key that may contain an accountId prefix. Legacy scene-only keys pass
|
|
154
|
+
* through unchanged.
|
|
155
|
+
*/
|
|
156
|
+
export function resolveBncrSceneKeyFromHistoryKey(historyKey: string): string {
|
|
157
|
+
const key = String(historyKey || '').trim();
|
|
158
|
+
const firstColon = key.indexOf(':');
|
|
159
|
+
if (firstColon > 0 && key.indexOf(':', firstColon + 1) !== -1) {
|
|
160
|
+
return key.slice(firstColon + 1);
|
|
161
|
+
}
|
|
162
|
+
return key;
|
|
163
|
+
}
|
|
164
|
+
|
|
117
165
|
export function resolveBncrConversationHistoryMessageId(parsed: ParsedInbound): string | undefined {
|
|
118
166
|
const msgId = String(parsed.msgId || '').trim();
|
|
119
167
|
return msgId || undefined;
|
|
@@ -57,9 +57,9 @@ export type NativeSessionResetCommand = {
|
|
|
57
57
|
* the same code path as an admin caller. This whitelist is shared
|
|
58
58
|
* across permission checks so admin and non-admin logic stay in sync.
|
|
59
59
|
*
|
|
60
|
-
* `stop`
|
|
61
|
-
*
|
|
62
|
-
* the
|
|
60
|
+
* `stop` is intentionally absent: it is handled before this set by the
|
|
61
|
+
* unified stop fast path in dispatch.ts, and `isBncrStopCommandText` only
|
|
62
|
+
* accepts the exact `/stop` form.
|
|
63
63
|
*/
|
|
64
64
|
export const BNCR_SELF_SERVICE_COMMANDS: ReadonlySet<string> = new Set([
|
|
65
65
|
'whoami',
|
|
@@ -68,7 +68,6 @@ export const BNCR_SELF_SERVICE_COMMANDS: ReadonlySet<string> = new Set([
|
|
|
68
68
|
'model',
|
|
69
69
|
'new',
|
|
70
70
|
'reset',
|
|
71
|
-
'stop',
|
|
72
71
|
]);
|
|
73
72
|
|
|
74
73
|
/**
|
|
@@ -136,13 +135,7 @@ export const BNCR_NATIVE_COMMANDS = new Set([
|
|
|
136
135
|
'download-media',
|
|
137
136
|
]);
|
|
138
137
|
|
|
139
|
-
export const BNCR_DIRECT_ALLOWED_BARE_COMMANDS = new Set([
|
|
140
|
-
'whoami',
|
|
141
|
-
'status',
|
|
142
|
-
'new',
|
|
143
|
-
'reset',
|
|
144
|
-
'stop',
|
|
145
|
-
]);
|
|
138
|
+
export const BNCR_DIRECT_ALLOWED_BARE_COMMANDS = new Set(['whoami', 'status', 'new', 'reset']);
|
|
146
139
|
|
|
147
140
|
export const BNCR_DIRECT_ALLOWED_SUBCOMMANDS = new Set([
|
|
148
141
|
'help',
|
|
@@ -340,6 +333,9 @@ export function resolveBncrNativeSessionResetCommand(args: {
|
|
|
340
333
|
peerKind: 'direct' | 'group';
|
|
341
334
|
}): NativeSessionResetCommand | null {
|
|
342
335
|
if (args.command.command !== 'new' && args.command.command !== 'reset') return null;
|
|
336
|
+
// new/reset are exact session-reset commands. Extra arguments are not
|
|
337
|
+
// valid and must not silently reset or create a session.
|
|
338
|
+
if (String(args.command.argsText || '').trim()) return null;
|
|
343
339
|
const reason = args.command.command === 'new' ? 'new' : 'reset';
|
|
344
340
|
return {
|
|
345
341
|
handled: true,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeAccountId } from '../../core/accounts.ts';
|
|
1
2
|
import type {
|
|
2
3
|
BncrOutboundReplayEntry,
|
|
3
4
|
BncrOutboundReplayMediaEntry,
|
|
@@ -66,7 +67,7 @@ export function buildBncrOutboundReplayKeyFromRoute(args: {
|
|
|
66
67
|
groupId?: string;
|
|
67
68
|
userId?: string;
|
|
68
69
|
}): string | null {
|
|
69
|
-
const accountId =
|
|
70
|
+
const accountId = normalizeAccountId(args.accountId);
|
|
70
71
|
const platform = String(args.platform || '').trim();
|
|
71
72
|
const groupId = String(args.groupId || '0').trim() || '0';
|
|
72
73
|
const userId = String(args.userId || '0').trim() || '0';
|
|
@@ -167,6 +168,18 @@ export type BncrOutboundReplayRecordResult = {
|
|
|
167
168
|
historyVersion?: number;
|
|
168
169
|
};
|
|
169
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Record an outbound bot reply into both the conversation-history window and
|
|
173
|
+
* the outbound-replay cache.
|
|
174
|
+
*
|
|
175
|
+
* Dual-write strategy: the conversation-history window is the canonical
|
|
176
|
+
* ordered source for structured context (conversation_context). The
|
|
177
|
+
* outbound-replay cache is a lighter, indexed copy used by bridge-facing
|
|
178
|
+
* queries such as `listRecentOutbound`. Both are kept in sync for the
|
|
179
|
+
* duration of the runtime session. This is a compatibility design; the
|
|
180
|
+
* cache may be retired once all read paths are migrated to the history
|
|
181
|
+
* window.
|
|
182
|
+
*/
|
|
170
183
|
export function recordBncrOutboundReplay(args: {
|
|
171
184
|
cache: BncrOutboundReplayCache;
|
|
172
185
|
conversationHistories?: BncrConversationHistoryMap;
|
|
@@ -200,6 +213,7 @@ export function recordBncrOutboundReplay(args: {
|
|
|
200
213
|
if (!body) return { recorded: false, historyOverflow: false };
|
|
201
214
|
const recordTimestamp = args.entry.lastPushAt ?? Date.now();
|
|
202
215
|
const historyKey = buildBncrConversationHistoryKeyFromRoute({
|
|
216
|
+
accountId: args.entry.accountId,
|
|
203
217
|
platform: args.entry.route?.platform,
|
|
204
218
|
groupId: args.entry.route?.groupId,
|
|
205
219
|
userId: args.entry.route?.userId,
|
|
@@ -347,7 +361,9 @@ export function readBncrOutboundReplaySnapshot(args: {
|
|
|
347
361
|
: [];
|
|
348
362
|
if (!args.conversationHistories) return legacyEntries;
|
|
349
363
|
|
|
350
|
-
const historyKey = buildBncrConversationHistoryKey(args.parsed
|
|
364
|
+
const historyKey = buildBncrConversationHistoryKey(args.parsed, {
|
|
365
|
+
accountId: args.accountId,
|
|
366
|
+
});
|
|
351
367
|
const historyEntries = historyKey
|
|
352
368
|
? (args.conversationHistories.get(historyKey) || [])
|
|
353
369
|
.filter((entry) => entry.role === 'assistant')
|
|
@@ -247,6 +247,14 @@ export async function runBncrInboundReplyDispatch(args: {
|
|
|
247
247
|
sessionMetaBarrier.track(task);
|
|
248
248
|
},
|
|
249
249
|
},
|
|
250
|
+
// OpenClaw ≥8.1 requires prepared turns to carry a runDispatchLifecycle
|
|
251
|
+
// shim. Bncr dispatches directly via runDispatch and does not participate
|
|
252
|
+
// in the adoption lifecycle, so turnAdoptionLifecycle is intentionally
|
|
253
|
+
// left as undefined; onDispatchSkipped is a safe no-op here.
|
|
254
|
+
runDispatchLifecycle: {
|
|
255
|
+
turnAdoptionLifecycle: undefined,
|
|
256
|
+
onDispatchSkipped: async () => {},
|
|
257
|
+
},
|
|
250
258
|
runDispatch: async () => {
|
|
251
259
|
await sessionMetaBarrier.wait();
|
|
252
260
|
await correctBncrInboundSessionLabel({
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
2
3
|
import { dirname, join } from 'node:path';
|
|
3
4
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
5
|
|
|
@@ -12,7 +13,7 @@ type SessionResetParams = {
|
|
|
12
13
|
type SessionResetFn = (params: SessionResetParams) => Promise<SessionResetResult>;
|
|
13
14
|
|
|
14
15
|
let testOverride: SessionResetFn | null = null;
|
|
15
|
-
let
|
|
16
|
+
let resolvedSessionResetRuntimeCache: SessionResetFn | null = null;
|
|
16
17
|
|
|
17
18
|
export function setBncrSessionResetRuntimeForTest(fn: SessionResetFn | null): () => void {
|
|
18
19
|
const previous = testOverride;
|
|
@@ -22,56 +23,103 @@ export function setBncrSessionResetRuntimeForTest(fn: SessionResetFn | null): ()
|
|
|
22
23
|
};
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
function
|
|
26
|
-
|
|
27
|
-
let current = dirname(fileURLToPath(import.meta.url));
|
|
28
|
-
let pluginRoot: string | null = null;
|
|
26
|
+
function findPackageRoot(startPath: string, packageName: string): string | null {
|
|
27
|
+
let current = dirname(startPath);
|
|
29
28
|
while (true) {
|
|
30
29
|
const pkgPath = join(current, 'package.json');
|
|
31
30
|
try {
|
|
32
31
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { name?: string };
|
|
33
|
-
if (pkg.name ===
|
|
34
|
-
pluginRoot = current;
|
|
35
|
-
break;
|
|
36
|
-
}
|
|
32
|
+
if (pkg.name === packageName) return current;
|
|
37
33
|
} catch {
|
|
38
|
-
// Keep walking
|
|
34
|
+
// Keep walking toward the package root.
|
|
39
35
|
}
|
|
40
36
|
const parent = dirname(current);
|
|
41
|
-
if (parent === current)
|
|
37
|
+
if (parent === current) return null;
|
|
42
38
|
current = parent;
|
|
43
39
|
}
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolveOpenClawDistDir(): string {
|
|
43
|
+
let current = dirname(fileURLToPath(import.meta.url));
|
|
44
|
+
let resolvedOpenClawEntry: string | null = null;
|
|
45
|
+
try {
|
|
46
|
+
resolvedOpenClawEntry = createRequire(import.meta.url).resolve('openclaw');
|
|
47
|
+
} catch {
|
|
48
|
+
// Fall back to the plugin-local dependency path below.
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
join(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
51
|
+
const openClawRoot = resolvedOpenClawEntry
|
|
52
|
+
? findPackageRoot(resolvedOpenClawEntry, 'openclaw')
|
|
53
|
+
: null;
|
|
54
|
+
if (openClawRoot) {
|
|
55
|
+
const distDir = join(openClawRoot, 'dist');
|
|
56
|
+
if (existsSync(distDir)) return distDir;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
while (true) {
|
|
60
|
+
const pluginRoot = findPackageRoot(current, '@xmoxmo/bncr');
|
|
61
|
+
if (pluginRoot) {
|
|
62
|
+
const distDir = join(pluginRoot, 'node_modules', 'openclaw', 'dist');
|
|
63
|
+
if (existsSync(distDir)) return distDir;
|
|
59
64
|
}
|
|
65
|
+
const parent = dirname(current);
|
|
66
|
+
if (parent === current) break;
|
|
67
|
+
current = parent;
|
|
60
68
|
}
|
|
69
|
+
|
|
70
|
+
const resolvedPath = resolvedOpenClawEntry || '(unresolved)';
|
|
71
|
+
const pluginPath = join(
|
|
72
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
73
|
+
'node_modules',
|
|
74
|
+
'openclaw',
|
|
75
|
+
'dist',
|
|
76
|
+
);
|
|
61
77
|
throw new Error(
|
|
62
|
-
`OpenClaw
|
|
78
|
+
`OpenClaw dist directory not found (resolved entry: ${resolvedPath}; plugin fallback: ${pluginPath})`,
|
|
63
79
|
);
|
|
64
80
|
}
|
|
65
81
|
|
|
82
|
+
function resolveOpenClawSessionsRuntimeUrls(): string[] {
|
|
83
|
+
const distDir = resolveOpenClawDistDir();
|
|
84
|
+
if (!existsSync(distDir)) {
|
|
85
|
+
throw new Error(`OpenClaw dist directory not found: ${distDir}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Scan dist for sessions.runtime*.js, preferring the stable re-export.
|
|
89
|
+
const candidates = readdirSync(distDir)
|
|
90
|
+
.filter((name) => /^sessions\.runtime(?:-[A-Za-z0-9_-]+)?\.js$/.test(name))
|
|
91
|
+
.sort(
|
|
92
|
+
(a, b) =>
|
|
93
|
+
Number(b === 'sessions.runtime.js') - Number(a === 'sessions.runtime.js') ||
|
|
94
|
+
a.localeCompare(b),
|
|
95
|
+
);
|
|
96
|
+
return candidates.map((name) => pathToFileURL(join(distDir, name)).href);
|
|
97
|
+
}
|
|
98
|
+
|
|
66
99
|
export async function performBncrGatewaySessionReset(
|
|
67
100
|
params: SessionResetParams,
|
|
68
101
|
): Promise<SessionResetResult> {
|
|
69
102
|
if (testOverride) return testOverride(params);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
103
|
+
if (!resolvedSessionResetRuntimeCache) {
|
|
104
|
+
const candidates = resolveOpenClawSessionsRuntimeUrls();
|
|
105
|
+
const importErrors: string[] = [];
|
|
106
|
+
for (const url of candidates) {
|
|
107
|
+
try {
|
|
108
|
+
const mod = (await import(url)) as { performGatewaySessionReset?: SessionResetFn };
|
|
109
|
+
if (typeof mod.performGatewaySessionReset === 'function') {
|
|
110
|
+
resolvedSessionResetRuntimeCache = mod.performGatewaySessionReset;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
importErrors.push(`${url}: export unavailable`);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
importErrors.push(`${url}: ${error instanceof Error ? error.message : String(error)}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!resolvedSessionResetRuntimeCache) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`OpenClaw performGatewaySessionReset is unavailable; candidates: ${importErrors.join('; ') || '(none)'}`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
75
123
|
}
|
|
76
|
-
return
|
|
124
|
+
return resolvedSessionResetRuntimeCache(params);
|
|
77
125
|
}
|
|
@@ -86,7 +86,7 @@ export type BncrPersistedConversationHistoryEntry = {
|
|
|
86
86
|
export type BncrPersistedConversationHistoryMediaEntry = {
|
|
87
87
|
path?: string;
|
|
88
88
|
contentType?: string;
|
|
89
|
-
kind?: 'image' | 'video' | 'audio' | 'document' | 'unknown';
|
|
89
|
+
kind?: 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'unknown';
|
|
90
90
|
messageId?: string;
|
|
91
91
|
};
|
|
92
92
|
|
package/src/plugin/config.ts
CHANGED
|
@@ -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
|
-
|
|
330
|
-
|
|
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
|
}
|