@xmoxmo/bncr 0.6.4 → 0.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +146 -307
- package/package.json +7 -7
- package/scripts/check-pack.mjs +7 -1
- package/scripts/selfcheck.mjs +4 -1
- package/src/core/types.ts +1 -1
- package/src/messaging/inbound/command-owner.ts +33 -0
- package/src/messaging/inbound/commands.ts +427 -252
- package/src/messaging/inbound/conversation-history.ts +26 -5
- package/src/messaging/inbound/dispatch-prep.ts +8 -4
- package/src/messaging/inbound/dispatch.ts +3 -1
- package/src/messaging/inbound/native-command-runtime.ts +4 -0
- package/src/messaging/inbound/native-command.ts +92 -22
- package/src/messaging/inbound/outbound-replay-cache.ts +18 -2
- package/src/messaging/inbound/reply-dispatch.ts +25 -36
- package/src/messaging/inbound/scene-admin.ts +31 -3
- package/src/openclaw/inbound-session-runtime.ts +17 -0
- package/src/openclaw/session-reset-runtime.ts +125 -0
- package/src/plugin/channel-runtime-types.ts +1 -1
- package/src/plugin/inbound-acceptance.ts +18 -7
- package/src/plugin/sqlite-state.ts +59 -18
- package/src/plugin/state-store.ts +81 -20
|
@@ -5,6 +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
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,53 @@ 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
|
+
* Extract the scene key (platform:userId or platform:groupId) from a history
|
|
126
|
+
* key that may contain an accountId prefix. Legacy scene-only keys pass
|
|
127
|
+
* through unchanged.
|
|
128
|
+
*/
|
|
129
|
+
export function resolveBncrSceneKeyFromHistoryKey(historyKey: string): string {
|
|
130
|
+
const key = String(historyKey || '').trim();
|
|
131
|
+
const firstColon = key.indexOf(':');
|
|
132
|
+
if (firstColon > 0 && key.indexOf(':', firstColon + 1) !== -1) {
|
|
133
|
+
return key.slice(firstColon + 1);
|
|
134
|
+
}
|
|
135
|
+
return key;
|
|
136
|
+
}
|
|
137
|
+
|
|
117
138
|
export function resolveBncrConversationHistoryMessageId(parsed: ParsedInbound): string | undefined {
|
|
118
139
|
const msgId = String(parsed.msgId || '').trim();
|
|
119
140
|
return msgId || undefined;
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
} from '../../openclaw/routing-runtime.ts';
|
|
21
21
|
import type { BncrInboundApi, BncrInboundConfig, BncrRememberSessionRoute } from './contracts.ts';
|
|
22
22
|
import { INBOUND_MEDIA_URL_MAX_BYTES, isHttpMediaUrl } from './media-url-download.ts';
|
|
23
|
-
import { parseBncrNativeCommand } from './native-command.ts';
|
|
23
|
+
import { parseBncrNativeCommand, resolveBncrNativeCommandParseOptions } from './native-command.ts';
|
|
24
24
|
import { loadInboundRemoteMedia } from './remote-media.ts';
|
|
25
25
|
|
|
26
26
|
export type ParsedInbound = ReturnType<typeof import('./parse.ts')['parseBncrInboundParams']>;
|
|
@@ -460,9 +460,13 @@ export async function prepareBncrInboundDispatch(args: {
|
|
|
460
460
|
const senderIdForContext = parsed.userId || parsed.clientId || resolution.canonicalTo;
|
|
461
461
|
const senderDisplayName = parsed.userName || resolution.canonicalTo;
|
|
462
462
|
const isBncrNativeCommand =
|
|
463
|
-
parseBncrNativeCommand(
|
|
464
|
-
|
|
465
|
-
|
|
463
|
+
parseBncrNativeCommand(
|
|
464
|
+
parsed.extracted.text,
|
|
465
|
+
resolveBncrNativeCommandParseOptions({
|
|
466
|
+
isAdmin: parsed.isAdmin,
|
|
467
|
+
peerKind: parsed.peer.kind as 'direct' | 'group',
|
|
468
|
+
}),
|
|
469
|
+
) !== null;
|
|
466
470
|
const isOpenClawNativeCommand =
|
|
467
471
|
!isBncrNativeCommand &&
|
|
468
472
|
hasControlCommand(parsed.extracted.text, cfg as Parameters<typeof hasControlCommand>[1]);
|
|
@@ -176,6 +176,8 @@ export async function dispatchBncrInbound(params: {
|
|
|
176
176
|
// A stop command must not wait behind the history serial chain or mutate
|
|
177
177
|
// the accumulated conversation window. Deliver it immediately so the host
|
|
178
178
|
// can abort the active run instead of queuing behind it.
|
|
179
|
+
const stopOwnerAllowFrom =
|
|
180
|
+
parsed.peer.kind === 'direct' && senderIdForContext ? [senderIdForContext] : ownerAllowFrom;
|
|
179
181
|
const ctxPayload = await buildBncrInboundTurnContext({
|
|
180
182
|
api,
|
|
181
183
|
cfg,
|
|
@@ -185,7 +187,7 @@ export async function dispatchBncrInbound(params: {
|
|
|
185
187
|
peer,
|
|
186
188
|
senderIdForContext,
|
|
187
189
|
senderDisplayName,
|
|
188
|
-
ownerAllowFrom,
|
|
190
|
+
ownerAllowFrom: stopOwnerAllowFrom,
|
|
189
191
|
bridgeSenderId,
|
|
190
192
|
bridgeSenderName,
|
|
191
193
|
historyLimit: resolvedHistoryLimit,
|
|
@@ -81,6 +81,7 @@ export function createNativeCommandTurnContext(args: {
|
|
|
81
81
|
peer: ParsedInbound['peer'];
|
|
82
82
|
resolvedRoute: { sessionKey: string; agentId: string; mainSessionKey?: string };
|
|
83
83
|
sessionKey: string;
|
|
84
|
+
ownerAllowFrom?: string[];
|
|
84
85
|
displayTo: string;
|
|
85
86
|
originatingTo: string;
|
|
86
87
|
senderIdForContext: string;
|
|
@@ -167,6 +168,9 @@ export function createNativeCommandTurnContext(args: {
|
|
|
167
168
|
authorized: true,
|
|
168
169
|
body: args.body,
|
|
169
170
|
};
|
|
171
|
+
if (args.ownerAllowFrom?.length) {
|
|
172
|
+
ctx.OwnerAllowFrom = args.ownerAllowFrom;
|
|
173
|
+
}
|
|
170
174
|
return ctx;
|
|
171
175
|
});
|
|
172
176
|
}
|
|
@@ -50,7 +50,73 @@ export type NativeSessionResetCommand = {
|
|
|
50
50
|
text: string;
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Commands that non-admin private-chat users may self-elevate for.
|
|
55
|
+
* When a non-admin direct-chat caller issues one of these commands,
|
|
56
|
+
* the plugin temporarily treats them as admin so the command follows
|
|
57
|
+
* the same code path as an admin caller. This whitelist is shared
|
|
58
|
+
* across permission checks so admin and non-admin logic stay in sync.
|
|
59
|
+
*
|
|
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
|
+
*/
|
|
64
|
+
export const BNCR_SELF_SERVICE_COMMANDS: ReadonlySet<string> = new Set([
|
|
65
|
+
'whoami',
|
|
66
|
+
'status',
|
|
67
|
+
'verbose',
|
|
68
|
+
'model',
|
|
69
|
+
'new',
|
|
70
|
+
'reset',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Synthetic command name used when an admin bare command is routed to the
|
|
75
|
+
* OpenClaw native parser. It must never be exposed to the user.
|
|
76
|
+
*/
|
|
77
|
+
export const BNCR_OPENCLAW_NATIVE_COMMAND = '__openclaw_native__';
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Detect if the raw text is a bare whitelist command that should be
|
|
81
|
+
* elevated to admin for OpenClaw native parsing (private non-admin)
|
|
82
|
+
* or mapped to /bncr (group non-admin).
|
|
83
|
+
*/
|
|
84
|
+
export function isBncrWhitelistBareCommandText(rawBody: string): string | null {
|
|
85
|
+
const raw = String(rawBody || '')
|
|
86
|
+
.trim()
|
|
87
|
+
.toLowerCase();
|
|
88
|
+
if (!raw.startsWith('/')) return null;
|
|
89
|
+
const bare = raw.slice(1);
|
|
90
|
+
if (!bare) return null;
|
|
91
|
+
// Whitelist commands match the exact command name only. @bot suffixes are
|
|
92
|
+
// intentionally unsupported so bare commands stay consistent with /stop.
|
|
93
|
+
const commandName = bare.split(/\s+/, 1)[0];
|
|
94
|
+
if (!commandName) return null;
|
|
95
|
+
return BNCR_SELF_SERVICE_COMMANDS.has(commandName) ? commandName : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resolve which bare slash commands bncr should treat as native commands.
|
|
100
|
+
*
|
|
101
|
+
* Admin callers never have whitelist restrictions: their bare commands go to
|
|
102
|
+
* the OpenClaw parser. Non-admin group callers map the whitelist onto /bncr
|
|
103
|
+
* native commands; non-admin private callers are elevated and routed to the
|
|
104
|
+
* OpenClaw parser instead (handled before this helper runs).
|
|
105
|
+
*/
|
|
106
|
+
export function resolveBncrNativeCommandParseOptions(args: {
|
|
107
|
+
isAdmin: boolean;
|
|
108
|
+
peerKind: 'direct' | 'group';
|
|
109
|
+
}): ParseBncrNativeCommandOptions {
|
|
110
|
+
const isGroup = args.peerKind === 'group';
|
|
111
|
+
const isNonAdmin = args.isAdmin !== true;
|
|
112
|
+
return {
|
|
113
|
+
allowBareWhoami: isNonAdmin && isGroup,
|
|
114
|
+
allowBareStatus: isNonAdmin && isGroup,
|
|
115
|
+
allowBareSessionReset: isNonAdmin && isGroup,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const BNCR_NATIVE_COMMANDS = new Set([
|
|
54
120
|
'help',
|
|
55
121
|
'new',
|
|
56
122
|
'reset',
|
|
@@ -69,13 +135,7 @@ const BNCR_NATIVE_COMMANDS = new Set([
|
|
|
69
135
|
'download-media',
|
|
70
136
|
]);
|
|
71
137
|
|
|
72
|
-
export const BNCR_DIRECT_ALLOWED_BARE_COMMANDS = new Set([
|
|
73
|
-
'whoami',
|
|
74
|
-
'status',
|
|
75
|
-
'new',
|
|
76
|
-
'reset',
|
|
77
|
-
'stop',
|
|
78
|
-
]);
|
|
138
|
+
export const BNCR_DIRECT_ALLOWED_BARE_COMMANDS = new Set(['whoami', 'status', 'new', 'reset']);
|
|
79
139
|
|
|
80
140
|
export const BNCR_DIRECT_ALLOWED_SUBCOMMANDS = new Set([
|
|
81
141
|
'help',
|
|
@@ -128,7 +188,9 @@ export function parseBncrUnsupportedDirectCommand(text: string): {
|
|
|
128
188
|
const raw = String(text || '').trim();
|
|
129
189
|
if (!raw.startsWith('/')) return null;
|
|
130
190
|
|
|
131
|
-
|
|
191
|
+
// Hyphens are allowed in command names (e.g. /history-limit) so bare slash
|
|
192
|
+
// parsing matches the same command vocabulary as the /bncr subcommands.
|
|
193
|
+
const bareMatch = raw.match(/^\/([A-Za-z0-9_-]+)(?:\s+.*)?$/);
|
|
132
194
|
if (!bareMatch) return null;
|
|
133
195
|
const bareCommand = String(bareMatch[1] || '')
|
|
134
196
|
.trim()
|
|
@@ -158,7 +220,7 @@ const BNCR_HELP_SECTIONS = [
|
|
|
158
220
|
{ label: '/bncr status', scopes: ['admin', 'direct', 'group'] },
|
|
159
221
|
{ label: '/bncr new', scopes: ['admin', 'direct'] },
|
|
160
222
|
{ label: '/bncr reset', scopes: ['admin', 'direct'] },
|
|
161
|
-
{ label: '/bncr verbose on|off|full', scopes: ['admin'] },
|
|
223
|
+
{ label: '/bncr verbose on|off|full', scopes: ['admin', 'direct'] },
|
|
162
224
|
],
|
|
163
225
|
},
|
|
164
226
|
{
|
|
@@ -180,20 +242,20 @@ const BNCR_HELP_SECTIONS = [
|
|
|
180
242
|
},
|
|
181
243
|
{
|
|
182
244
|
title: '📋 Conversation history',
|
|
183
|
-
audience: '
|
|
245
|
+
audience: 'all',
|
|
184
246
|
commands: [
|
|
185
|
-
{ label: '/bncr history-help', scopes: ['admin'] },
|
|
186
|
-
{ label: '/bncr history-limit [<number>|clear] [<SceneId>]', scopes: ['admin'] },
|
|
187
|
-
{ label: '/bncr history-force on|off|clear [<SceneId>]', scopes: ['admin'] },
|
|
247
|
+
{ label: '/bncr history-help', scopes: ['admin', 'direct'] },
|
|
248
|
+
{ label: '/bncr history-limit [<number>|clear] [<SceneId>]', scopes: ['admin', 'direct'] },
|
|
249
|
+
{ label: '/bncr history-force on|off|clear [<SceneId>]', scopes: ['admin', 'direct'] },
|
|
188
250
|
],
|
|
189
251
|
},
|
|
190
252
|
{
|
|
191
253
|
title: '🌐 Remote media',
|
|
192
|
-
audience: '
|
|
254
|
+
audience: 'all',
|
|
193
255
|
commands: [
|
|
194
256
|
{
|
|
195
257
|
label: '/bncr download-media on|off|clear|default on|off [<SceneId>]',
|
|
196
|
-
scopes: ['admin'],
|
|
258
|
+
scopes: ['admin', 'direct'],
|
|
197
259
|
},
|
|
198
260
|
],
|
|
199
261
|
},
|
|
@@ -271,27 +333,35 @@ export function resolveBncrNativeSessionResetCommand(args: {
|
|
|
271
333
|
peerKind: 'direct' | 'group';
|
|
272
334
|
}): NativeSessionResetCommand | null {
|
|
273
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;
|
|
274
339
|
const reason = args.command.command === 'new' ? 'new' : 'reset';
|
|
275
|
-
const scope = args.peerKind === 'group' ? 'this group chat' : 'this private chat';
|
|
276
340
|
return {
|
|
277
341
|
handled: true,
|
|
278
342
|
reason,
|
|
279
|
-
text:
|
|
280
|
-
reason === 'new'
|
|
281
|
-
? `Started a new session for ${scope}.`
|
|
282
|
-
: `Reset the current session for ${scope}.`,
|
|
343
|
+
text: reason === 'new' ? '✅ New session started.' : '✅ Session reset.',
|
|
283
344
|
};
|
|
284
345
|
}
|
|
285
346
|
|
|
286
347
|
export function resolveBncrNativeVerboseCommand(
|
|
287
348
|
command: NativeCommand,
|
|
349
|
+
currentLevel?: 'on' | 'off' | 'full',
|
|
288
350
|
): NativeVerboseCommand | null {
|
|
289
351
|
if (command.command !== 'verbose') return null;
|
|
290
352
|
const rawLevel = String(command.argsText || '')
|
|
291
353
|
.trim()
|
|
292
354
|
.toLowerCase();
|
|
293
355
|
if (!rawLevel || rawLevel === 'status') {
|
|
294
|
-
|
|
356
|
+
const label =
|
|
357
|
+
currentLevel === 'full'
|
|
358
|
+
? 'full'
|
|
359
|
+
: currentLevel === 'off'
|
|
360
|
+
? 'off'
|
|
361
|
+
: currentLevel === 'on'
|
|
362
|
+
? 'on'
|
|
363
|
+
: 'default';
|
|
364
|
+
return { handled: true, text: `Current verbose level: ${label}` };
|
|
295
365
|
}
|
|
296
366
|
if (rawLevel === 'on')
|
|
297
367
|
return { handled: true, verboseLevel: 'on', text: 'Verbose logging enabled.' };
|
|
@@ -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')
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
resolveBncrPinnedMainDmOwnerFromAllowlist,
|
|
7
7
|
} from '../../openclaw/inbound-session-runtime.ts';
|
|
8
8
|
import { dispatchOpenClawReplyWithBufferedBlockDispatcher } from '../../openclaw/reply-runtime.ts';
|
|
9
|
+
import { mergeBncrOwnerAllowFromIntoConfig } from './command-owner.ts';
|
|
9
10
|
import type {
|
|
10
11
|
BncrEnqueueFromReply,
|
|
11
12
|
BncrInboundApi,
|
|
@@ -18,7 +19,11 @@ import type {
|
|
|
18
19
|
ParsedInbound,
|
|
19
20
|
} from './dispatch-prep.ts';
|
|
20
21
|
import { buildBncrInboundRecordUpdateLastRoute } from './last-route.ts';
|
|
21
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
isBncrStopCommandText,
|
|
24
|
+
parseBncrNativeCommand,
|
|
25
|
+
resolveBncrNativeCommandParseOptions,
|
|
26
|
+
} from './native-command.ts';
|
|
22
27
|
import { buildBncrReplyConfig } from './reply-config.ts';
|
|
23
28
|
import { runBncrReplyDispatchSerial } from './reply-dispatch-serial.ts';
|
|
24
29
|
import { resolveBncrChannelInboundRuntime } from './runtime-compat.ts';
|
|
@@ -29,32 +34,6 @@ import {
|
|
|
29
34
|
} from './session-label.ts';
|
|
30
35
|
import { createBncrSessionMetaTaskBarrier } from './session-meta-task.ts';
|
|
31
36
|
|
|
32
|
-
function mergeBncrCommandOwnerAllowFrom(args: {
|
|
33
|
-
cfg: BncrInboundConfig;
|
|
34
|
-
parsed: ParsedInbound;
|
|
35
|
-
isBncrNativeCommand: boolean;
|
|
36
|
-
senderIdForContext: string;
|
|
37
|
-
}) {
|
|
38
|
-
const { cfg, parsed, isBncrNativeCommand, senderIdForContext } = args;
|
|
39
|
-
if (parsed.isAdmin !== true || isBncrNativeCommand) return cfg;
|
|
40
|
-
const senderId = String(senderIdForContext || '').trim();
|
|
41
|
-
if (!senderId) return cfg;
|
|
42
|
-
|
|
43
|
-
const currentCommands = (cfg.commands || {}) as { ownerAllowFrom?: string[] };
|
|
44
|
-
const currentOwnerAllowFrom = Array.isArray(currentCommands.ownerAllowFrom)
|
|
45
|
-
? currentCommands.ownerAllowFrom
|
|
46
|
-
: [];
|
|
47
|
-
if (currentOwnerAllowFrom.includes(senderId)) return cfg;
|
|
48
|
-
|
|
49
|
-
return {
|
|
50
|
-
...cfg,
|
|
51
|
-
commands: {
|
|
52
|
-
...currentCommands,
|
|
53
|
-
ownerAllowFrom: [...currentOwnerAllowFrom, senderId],
|
|
54
|
-
},
|
|
55
|
-
} satisfies BncrInboundConfig;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
37
|
function sleep(ms: number) {
|
|
59
38
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
60
39
|
}
|
|
@@ -180,15 +159,17 @@ export async function runBncrInboundReplyDispatch(args: {
|
|
|
180
159
|
pinnedMainDmOwner,
|
|
181
160
|
});
|
|
182
161
|
const isBncrNativeCommand =
|
|
183
|
-
parseBncrNativeCommand(
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
162
|
+
parseBncrNativeCommand(
|
|
163
|
+
rawBody,
|
|
164
|
+
resolveBncrNativeCommandParseOptions({
|
|
165
|
+
isAdmin: parsed.isAdmin,
|
|
166
|
+
peerKind: parsed.peer.kind as 'direct' | 'group',
|
|
167
|
+
}),
|
|
168
|
+
) !== null;
|
|
169
|
+
const commandDispatchCfg =
|
|
170
|
+
parsed.isAdmin === true && !isBncrNativeCommand
|
|
171
|
+
? mergeBncrOwnerAllowFromIntoConfig({ cfg, senderIdForContext })
|
|
172
|
+
: cfg;
|
|
192
173
|
|
|
193
174
|
if (!shouldDispatch) {
|
|
194
175
|
await wrapBncrInboundRecordSessionLabelCorrection({
|
|
@@ -266,6 +247,14 @@ export async function runBncrInboundReplyDispatch(args: {
|
|
|
266
247
|
sessionMetaBarrier.track(task);
|
|
267
248
|
},
|
|
268
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
|
+
},
|
|
269
258
|
runDispatch: async () => {
|
|
270
259
|
await sessionMetaBarrier.wait();
|
|
271
260
|
await correctBncrInboundSessionLabel({
|
|
@@ -435,13 +435,41 @@ export function executeSceneAdminCommand(args: {
|
|
|
435
435
|
defaultAdminAgentId: string;
|
|
436
436
|
defaultPublicAgentId: string;
|
|
437
437
|
now: () => number;
|
|
438
|
+
/**
|
|
439
|
+
* When true, non-admin callers in direct chat are allowed to execute
|
|
440
|
+
* self-admin commands (history-*, download-media) scoped to
|
|
441
|
+
* their own session only. The flag is set by the caller after an
|
|
442
|
+
* explicit allow-list check – it must NOT be granted unconditionally
|
|
443
|
+
* for direct chat.
|
|
444
|
+
*/
|
|
445
|
+
allowNonAdminSelfAdmin?: boolean;
|
|
438
446
|
}): { ok: true; text: string } | { ok: false; text: string } {
|
|
439
|
-
const {
|
|
440
|
-
|
|
441
|
-
|
|
447
|
+
const {
|
|
448
|
+
parsed,
|
|
449
|
+
command,
|
|
450
|
+
sceneRegistry,
|
|
451
|
+
defaultAdminAgentId,
|
|
452
|
+
defaultPublicAgentId,
|
|
453
|
+
now,
|
|
454
|
+
allowNonAdminSelfAdmin,
|
|
455
|
+
} = args;
|
|
456
|
+
|
|
457
|
+
// Admin always passes; non-admin only passes when the caller explicitly
|
|
458
|
+
// opted in via allowNonAdminSelfAdmin (self-admin commands in direct chat).
|
|
459
|
+
if (!parsed.isAdmin && !allowNonAdminSelfAdmin) {
|
|
442
460
|
return { ok: false, text: 'Admin permission required.' };
|
|
443
461
|
}
|
|
444
462
|
|
|
463
|
+
// Non-admin self-admin: enforce that sceneId targets only the caller's own session.
|
|
464
|
+
if (allowNonAdminSelfAdmin && !parsed.isAdmin) {
|
|
465
|
+
const currentSceneKey = resolveCurrentSceneKey(parsed);
|
|
466
|
+
const cmdSceneKey =
|
|
467
|
+
'sceneKey' in command ? (command as { sceneKey?: string }).sceneKey : undefined;
|
|
468
|
+
if (cmdSceneKey && currentSceneKey && cmdSceneKey !== currentSceneKey) {
|
|
469
|
+
return { ok: false, text: 'You can only manage your own private chat session.' };
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
445
473
|
if (command.kind === 'history-help') {
|
|
446
474
|
return { ok: true, text: HISTORY_HELP_TEXT };
|
|
447
475
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { recordInboundSession as sdkRecordInboundSession } from 'openclaw/plugin-sdk/conversation-runtime';
|
|
2
2
|
import { resolvePinnedMainDmOwnerFromAllowlist as sdkResolvePinnedMainDmOwnerFromAllowlist } from 'openclaw/plugin-sdk/security-runtime';
|
|
3
3
|
import {
|
|
4
|
+
getSessionEntry as sdkGetSessionEntry,
|
|
4
5
|
recordSessionMetaFromInbound as sdkRecordSessionMetaFromInbound,
|
|
5
6
|
resolveStorePath as sdkResolveStorePath,
|
|
6
7
|
updateSessionStoreEntry as sdkUpdateSessionStoreEntry,
|
|
@@ -14,6 +15,7 @@ type ResolveStorePathFn = (
|
|
|
14
15
|
type RecordInboundSessionFn = typeof sdkRecordInboundSession;
|
|
15
16
|
type RecordSessionMetaFromInboundFn = typeof sdkRecordSessionMetaFromInbound;
|
|
16
17
|
type UpdateSessionStoreEntryFn = typeof sdkUpdateSessionStoreEntry;
|
|
18
|
+
type GetSessionEntryFn = typeof sdkGetSessionEntry;
|
|
17
19
|
type ReadSessionUpdatedAtFn = (params: { storePath: string; sessionKey: string }) => unknown;
|
|
18
20
|
type ResolvePinnedMainDmOwnerFromAllowlistFn = typeof sdkResolvePinnedMainDmOwnerFromAllowlist;
|
|
19
21
|
|
|
@@ -22,6 +24,7 @@ type BncrInboundSessionRuntime = {
|
|
|
22
24
|
recordInboundSession: RecordInboundSessionFn;
|
|
23
25
|
recordSessionMetaFromInbound: RecordSessionMetaFromInboundFn;
|
|
24
26
|
updateSessionStoreEntry: UpdateSessionStoreEntryFn;
|
|
27
|
+
getSessionEntry: GetSessionEntryFn;
|
|
25
28
|
readSessionUpdatedAt?: ReadSessionUpdatedAtFn;
|
|
26
29
|
resolvePinnedMainDmOwnerFromAllowlist: ResolvePinnedMainDmOwnerFromAllowlistFn;
|
|
27
30
|
};
|
|
@@ -36,6 +39,7 @@ function resolveRuntime(): BncrInboundSessionRuntime {
|
|
|
36
39
|
testRuntimeOverride?.recordSessionMetaFromInbound ?? sdkRecordSessionMetaFromInbound,
|
|
37
40
|
updateSessionStoreEntry:
|
|
38
41
|
testRuntimeOverride?.updateSessionStoreEntry ?? sdkUpdateSessionStoreEntry,
|
|
42
|
+
getSessionEntry: testRuntimeOverride?.getSessionEntry ?? sdkGetSessionEntry,
|
|
39
43
|
readSessionUpdatedAt: testRuntimeOverride?.readSessionUpdatedAt,
|
|
40
44
|
resolvePinnedMainDmOwnerFromAllowlist:
|
|
41
45
|
testRuntimeOverride?.resolvePinnedMainDmOwnerFromAllowlist ??
|
|
@@ -81,6 +85,19 @@ export function readBncrSessionUpdatedAt(
|
|
|
81
85
|
return readSessionUpdatedAt(params);
|
|
82
86
|
}
|
|
83
87
|
|
|
88
|
+
export async function readBncrSessionEntry(params: {
|
|
89
|
+
storePath: string;
|
|
90
|
+
sessionKey: string;
|
|
91
|
+
}): Promise<Record<string, unknown> | undefined> {
|
|
92
|
+
const runtime = resolveRuntime();
|
|
93
|
+
try {
|
|
94
|
+
const entry = await runtime.getSessionEntry(params);
|
|
95
|
+
return entry && typeof entry === 'object' ? (entry as Record<string, unknown>) : undefined;
|
|
96
|
+
} catch {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
84
101
|
export function resolveBncrPinnedMainDmOwnerFromAllowlist(
|
|
85
102
|
params: Parameters<ResolvePinnedMainDmOwnerFromAllowlistFn>[0],
|
|
86
103
|
): ReturnType<ResolvePinnedMainDmOwnerFromAllowlistFn> {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
|
+
|
|
6
|
+
type SessionResetResult = { ok?: boolean } | undefined;
|
|
7
|
+
type SessionResetParams = {
|
|
8
|
+
key: string;
|
|
9
|
+
reason: 'new' | 'reset';
|
|
10
|
+
agentId?: string;
|
|
11
|
+
commandSource?: string;
|
|
12
|
+
};
|
|
13
|
+
type SessionResetFn = (params: SessionResetParams) => Promise<SessionResetResult>;
|
|
14
|
+
|
|
15
|
+
let testOverride: SessionResetFn | null = null;
|
|
16
|
+
let resolvedSessionResetRuntimeCache: SessionResetFn | null = null;
|
|
17
|
+
|
|
18
|
+
export function setBncrSessionResetRuntimeForTest(fn: SessionResetFn | null): () => void {
|
|
19
|
+
const previous = testOverride;
|
|
20
|
+
testOverride = fn;
|
|
21
|
+
return () => {
|
|
22
|
+
testOverride = previous;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function findPackageRoot(startPath: string, packageName: string): string | null {
|
|
27
|
+
let current = dirname(startPath);
|
|
28
|
+
while (true) {
|
|
29
|
+
const pkgPath = join(current, 'package.json');
|
|
30
|
+
try {
|
|
31
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { name?: string };
|
|
32
|
+
if (pkg.name === packageName) return current;
|
|
33
|
+
} catch {
|
|
34
|
+
// Keep walking toward the package root.
|
|
35
|
+
}
|
|
36
|
+
const parent = dirname(current);
|
|
37
|
+
if (parent === current) return null;
|
|
38
|
+
current = parent;
|
|
39
|
+
}
|
|
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.
|
|
49
|
+
}
|
|
50
|
+
|
|
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;
|
|
64
|
+
}
|
|
65
|
+
const parent = dirname(current);
|
|
66
|
+
if (parent === current) break;
|
|
67
|
+
current = parent;
|
|
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
|
+
);
|
|
77
|
+
throw new Error(
|
|
78
|
+
`OpenClaw dist directory not found (resolved entry: ${resolvedPath}; plugin fallback: ${pluginPath})`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
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
|
+
|
|
99
|
+
export async function performBncrGatewaySessionReset(
|
|
100
|
+
params: SessionResetParams,
|
|
101
|
+
): Promise<SessionResetResult> {
|
|
102
|
+
if (testOverride) return testOverride(params);
|
|
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
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return resolvedSessionResetRuntimeCache(params);
|
|
125
|
+
}
|