@xmoxmo/bncr 0.6.5 → 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 -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 +43 -2
- package/src/messaging/inbound/conversation-history.ts +26 -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/sqlite-state.ts +59 -18
- package/src/plugin/state-store.ts +81 -20
|
@@ -120,8 +120,49 @@ export async function handleBncrNativeCommand(params: {
|
|
|
120
120
|
|
|
121
121
|
// Stop always goes through the unified stop fast path in dispatch.ts.
|
|
122
122
|
// It is not a bncr native command and must never be processed here.
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
// Only exact `/stop` is accepted; any other `/stop*` form is rejected.
|
|
124
|
+
const rawStopText = String(extracted.text || '')
|
|
125
|
+
.trim()
|
|
126
|
+
.toLowerCase();
|
|
127
|
+
if (rawStopText.startsWith('/stop')) {
|
|
128
|
+
if (isBncrStopCommandText(extracted.text)) {
|
|
129
|
+
return { handled: false };
|
|
130
|
+
}
|
|
131
|
+
// Non-exact `/stop` forms (extra args, @bot suffix, or other extension)
|
|
132
|
+
// are rejected immediately so they never reach the agent.
|
|
133
|
+
const rejectedRoute = buildBncrNativeCommandResolvedRoute({
|
|
134
|
+
api,
|
|
135
|
+
cfg,
|
|
136
|
+
channelId,
|
|
137
|
+
accountId,
|
|
138
|
+
peer,
|
|
139
|
+
resolvedAgentId,
|
|
140
|
+
});
|
|
141
|
+
const { baseSessionKey, sessionKey } = buildBncrNativeCommandSessionState({
|
|
142
|
+
parsed,
|
|
143
|
+
sessionAgentId: rejectedRoute.agentId || canonicalAgentId,
|
|
144
|
+
resolvedRoute: rejectedRoute,
|
|
145
|
+
});
|
|
146
|
+
logBncrNativeCommandSummary(
|
|
147
|
+
buildBncrNativeCommandSummary({
|
|
148
|
+
kind: 'stop',
|
|
149
|
+
command: rawStopText,
|
|
150
|
+
accountId,
|
|
151
|
+
to: formatDisplayScope(route),
|
|
152
|
+
msgId: msgId || null,
|
|
153
|
+
result: 'rejected',
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
await enqueueFromReply({
|
|
157
|
+
accountId,
|
|
158
|
+
sessionKey: baseSessionKey,
|
|
159
|
+
route,
|
|
160
|
+
payload: {
|
|
161
|
+
text: 'Only exact /stop is supported. Send /stop to stop.',
|
|
162
|
+
replyToId: msgId || undefined,
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
return { handled: true, command: 'stop', sessionKey };
|
|
125
166
|
}
|
|
126
167
|
|
|
127
168
|
const whitelistBareCommand =
|
|
@@ -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;
|
|
@@ -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
|
|
|
@@ -1,5 +1,6 @@
|
|
|
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
4
|
import {
|
|
4
5
|
dumpRegisterDriftSnapshot,
|
|
5
6
|
normalizeRegisterDriftSnapshot,
|
|
@@ -776,19 +777,43 @@ function loadControlState(db: DatabaseSync): BncrSqliteControlState {
|
|
|
776
777
|
}
|
|
777
778
|
|
|
778
779
|
function deriveHistoryKeyFromRoute(args: {
|
|
780
|
+
accountId?: string | null;
|
|
779
781
|
platform?: string;
|
|
780
782
|
groupId?: string;
|
|
781
783
|
userId?: string;
|
|
782
784
|
}): string | null {
|
|
785
|
+
const accountId = normalizeAccountId(args.accountId);
|
|
783
786
|
const platform = String(args.platform || '').trim();
|
|
784
787
|
const groupId = String(args.groupId || '0').trim() || '0';
|
|
785
788
|
const userId = String(args.userId || '0').trim() || '0';
|
|
786
789
|
if (!platform) return null;
|
|
787
|
-
if (groupId !== '0') return `${platform}:${groupId}`;
|
|
788
|
-
if (userId !== '0') return `${platform}:${userId}`;
|
|
790
|
+
if (groupId !== '0') return `${accountId}:${platform}:${groupId}`;
|
|
791
|
+
if (userId !== '0') return `${accountId}:${platform}:${userId}`;
|
|
789
792
|
return null;
|
|
790
793
|
}
|
|
791
794
|
|
|
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
|
+
|
|
792
817
|
function parseMediaJson(value: string) {
|
|
793
818
|
try {
|
|
794
819
|
const parsed = JSON.parse(value ?? '[]');
|
|
@@ -861,7 +886,7 @@ function replaceHistoryMessages(
|
|
|
861
886
|
const seenReplayMessageIds = new Map<string, Set<string>>();
|
|
862
887
|
|
|
863
888
|
for (const bucket of historyBuckets) {
|
|
864
|
-
const historyKey = String(bucket.key || '').trim();
|
|
889
|
+
const historyKey = normalizePersistedHistoryKey(String(bucket.key || '').trim());
|
|
865
890
|
if (!historyKey) continue;
|
|
866
891
|
for (const entry of Array.isArray(bucket.entries) ? bucket.entries : []) {
|
|
867
892
|
const sender = String(entry?.sender || '').trim();
|
|
@@ -909,12 +934,22 @@ function replaceHistoryMessages(
|
|
|
909
934
|
}
|
|
910
935
|
|
|
911
936
|
for (const bucket of replayBuckets) {
|
|
912
|
-
const
|
|
913
|
-
if (!
|
|
937
|
+
const bucketKey = normalizePersistedHistoryKey(String(bucket.key || '').trim());
|
|
938
|
+
if (!bucketKey) continue;
|
|
914
939
|
for (const entry of Array.isArray(bucket.entries) ? bucket.entries : []) {
|
|
915
940
|
const sender = String(entry?.sender || '').trim();
|
|
916
941
|
const body = String(entry?.body || '').trim();
|
|
917
942
|
if (!sender || !body) continue;
|
|
943
|
+
const entryAccountId = entry.accountId
|
|
944
|
+
? normalizeAccountId(entry.accountId)
|
|
945
|
+
: resolvePersistedHistoryAccountId(bucketKey);
|
|
946
|
+
const routeHistoryKey = deriveHistoryKeyFromRoute({
|
|
947
|
+
accountId: entryAccountId,
|
|
948
|
+
platform: entry.route?.platform,
|
|
949
|
+
groupId: entry.route?.groupId,
|
|
950
|
+
userId: entry.route?.userId,
|
|
951
|
+
});
|
|
952
|
+
const bufferKey = routeHistoryKey || resolvePersistedHistoryKey(bucketKey, entryAccountId);
|
|
918
953
|
if (entry.messageId) {
|
|
919
954
|
const seen = seenReplayMessageIds.get(bufferKey) ?? new Set<string>();
|
|
920
955
|
if (seen.has(entry.messageId)) continue;
|
|
@@ -930,16 +965,12 @@ function replaceHistoryMessages(
|
|
|
930
965
|
if (entry.messageId && isConsumedReplayMessage.get(bufferKey, entry.messageId)) {
|
|
931
966
|
continue;
|
|
932
967
|
}
|
|
933
|
-
const historyKey =
|
|
934
|
-
platform: entry.route?.platform,
|
|
935
|
-
groupId: entry.route?.groupId,
|
|
936
|
-
userId: entry.route?.userId,
|
|
937
|
-
});
|
|
968
|
+
const historyKey = routeHistoryKey || bufferKey;
|
|
938
969
|
insert.run(
|
|
939
970
|
'replay',
|
|
940
971
|
historyKey ?? '',
|
|
941
972
|
bufferKey,
|
|
942
|
-
|
|
973
|
+
entryAccountId,
|
|
943
974
|
sender,
|
|
944
975
|
entry.senderId ?? null,
|
|
945
976
|
'assistant',
|
|
@@ -1807,13 +1838,23 @@ export function createBncrSqliteStateDatabase(
|
|
|
1807
1838
|
}
|
|
1808
1839
|
|
|
1809
1840
|
function normalizeHistoryKeySkipList(skipHistoryKeys: readonly string[] | undefined): string[] {
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
)
|
|
1816
|
-
|
|
1841
|
+
const normalized: string[] = [];
|
|
1842
|
+
for (const rawKey of skipHistoryKeys ?? []) {
|
|
1843
|
+
const key = String(rawKey || '').trim();
|
|
1844
|
+
if (!key) continue;
|
|
1845
|
+
normalized.push(key);
|
|
1846
|
+
const legacyKey = toLegacyHistoryKey(key);
|
|
1847
|
+
if (legacyKey && legacyKey !== key) normalized.push(legacyKey);
|
|
1848
|
+
}
|
|
1849
|
+
return Array.from(new Set(normalized));
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
function toLegacyHistoryKey(historyKey: string): string {
|
|
1853
|
+
const firstColon = historyKey.indexOf(':');
|
|
1854
|
+
if (firstColon <= 0) return '';
|
|
1855
|
+
const secondColon = historyKey.indexOf(':', firstColon + 1);
|
|
1856
|
+
if (secondColon <= firstColon + 1) return '';
|
|
1857
|
+
return historyKey.slice(firstColon + 1);
|
|
1817
1858
|
}
|
|
1818
1859
|
|
|
1819
1860
|
function buildHistoryKeySkipCondition(skipped: readonly string[]): string {
|