@xmoxmo/bncr 0.6.1 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -1
- package/package.json +1 -1
- package/scripts/check-pack.mjs +4 -1
- package/scripts/selfcheck.mjs +4 -1
- package/src/channel.ts +144 -5
- package/src/messaging/inbound/commands.ts +62 -26
- package/src/messaging/inbound/context-facts.ts +88 -58
- package/src/messaging/inbound/conversation-history-serial.ts +258 -0
- package/src/messaging/inbound/conversation-history.ts +450 -0
- package/src/messaging/inbound/dispatch.ts +568 -123
- package/src/messaging/inbound/history-shard-worker.ts +180 -0
- package/src/messaging/inbound/native-command.ts +89 -33
- package/src/messaging/inbound/outbound-replay-cache.ts +202 -17
- package/src/messaging/inbound/reply-dispatch.ts +7 -6
- package/src/messaging/inbound/scene-admin.ts +40 -20
- package/src/messaging/inbound/turn-context.ts +101 -37
- package/src/plugin/bridge-lifecycle.ts +8 -0
- package/src/plugin/bridge-outbox-facade.ts +15 -1
- package/src/plugin/channel-components.ts +5 -5
- package/src/plugin/channel-runtime-builders-delivery.ts +2 -2
- package/src/plugin/channel-runtime-constants.ts +2 -0
- package/src/plugin/channel-runtime-types.ts +9 -7
- package/src/plugin/inbound-acceptance.ts +9 -24
- package/src/plugin/inbound-handlers.ts +4 -4
- package/src/plugin/inbound-surface-handlers-group.ts +4 -2
- package/src/plugin/outbox-drain-post-push.ts +3 -0
- package/src/plugin/scene-registry.ts +4 -4
- package/src/plugin/sqlite-state.ts +2109 -0
- package/src/plugin/state-store.ts +731 -69
- package/src/plugin/state-transient-runtime-group.ts +17 -3
- package/src/messaging/inbound/group-history.ts +0 -264
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { emitBncrLogLine } from '../../core/logging.ts';
|
|
2
|
+
import type { BncrHistoryShardQueue, BncrHistoryShardRow } from '../../plugin/sqlite-state.ts';
|
|
3
|
+
import type { BncrInboundContextPayload } from './contracts.ts';
|
|
4
|
+
import type { BncrConversationHistoryMap } from './conversation-history.ts';
|
|
5
|
+
import { removeBncrConversationHistoryMessageIds } from './conversation-history.ts';
|
|
6
|
+
import {
|
|
7
|
+
getConversationHistorySerialOwner,
|
|
8
|
+
runConversationHistorySerial,
|
|
9
|
+
} from './conversation-history-serial.ts';
|
|
10
|
+
import type {
|
|
11
|
+
BncrInboundConversationResolution,
|
|
12
|
+
BncrInboundReplyRouteFact,
|
|
13
|
+
ParsedInbound,
|
|
14
|
+
} from './dispatch-prep.ts';
|
|
15
|
+
import type { BncrOutboundReplayCache } from './outbound-replay-cache.ts';
|
|
16
|
+
import { removeBncrOutboundReplayMessageIds } from './outbound-replay-cache.ts';
|
|
17
|
+
|
|
18
|
+
export type BncrHistoryShardDispatchPayload = {
|
|
19
|
+
parsed: ParsedInbound;
|
|
20
|
+
msgId?: string | null;
|
|
21
|
+
peer: ParsedInbound['peer'];
|
|
22
|
+
rawBody: string;
|
|
23
|
+
storePath: string;
|
|
24
|
+
ctxPayload: BncrInboundContextPayload;
|
|
25
|
+
resolution: BncrInboundConversationResolution;
|
|
26
|
+
replyRouteFact: BncrInboundReplyRouteFact;
|
|
27
|
+
senderIdForContext: string;
|
|
28
|
+
senderDisplayName: string;
|
|
29
|
+
shouldDispatch: boolean;
|
|
30
|
+
silentHistoryFlush?: boolean;
|
|
31
|
+
deliveryId?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type BncrHistoryShardPayloadEnvelope = {
|
|
35
|
+
version: 2;
|
|
36
|
+
historyKey: string;
|
|
37
|
+
accountId?: string;
|
|
38
|
+
messageIds: string[];
|
|
39
|
+
bufferKeys: string[];
|
|
40
|
+
dispatch: BncrHistoryShardDispatchPayload;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const HISTORY_SHARD_LEASE_RENEW_INTERVAL_MS = 60_000;
|
|
44
|
+
|
|
45
|
+
export async function runBncrHistoryShardWithLeaseRenewal<T>(args: {
|
|
46
|
+
shardId: number | null;
|
|
47
|
+
historyShardQueue?: BncrHistoryShardQueue;
|
|
48
|
+
task: () => Promise<T>;
|
|
49
|
+
intervalMs?: number;
|
|
50
|
+
owner?: string;
|
|
51
|
+
}): Promise<T> {
|
|
52
|
+
if (args.shardId === null || !args.historyShardQueue?.renewHistoryShardLease) {
|
|
53
|
+
return args.task();
|
|
54
|
+
}
|
|
55
|
+
const renew = () => {
|
|
56
|
+
try {
|
|
57
|
+
args.historyShardQueue?.renewHistoryShardLease?.(args.shardId!, args.owner);
|
|
58
|
+
} catch {
|
|
59
|
+
// Lease renewal is best-effort; the claim itself protects against a stale lease.
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const timer = setInterval(renew, args.intervalMs ?? HISTORY_SHARD_LEASE_RENEW_INTERVAL_MS);
|
|
63
|
+
timer.unref?.();
|
|
64
|
+
try {
|
|
65
|
+
return await args.task();
|
|
66
|
+
} finally {
|
|
67
|
+
clearInterval(timer);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function parseBncrHistoryShardPayload(value: string): BncrHistoryShardPayloadEnvelope {
|
|
72
|
+
const parsed = JSON.parse(value) as Partial<BncrHistoryShardPayloadEnvelope>;
|
|
73
|
+
if (parsed?.version !== 2) {
|
|
74
|
+
throw new Error(`unsupported history shard payload version=${String(parsed?.version)}`);
|
|
75
|
+
}
|
|
76
|
+
if (!parsed.dispatch || !parsed.historyKey) {
|
|
77
|
+
throw new Error('invalid history shard payload: missing dispatch payload');
|
|
78
|
+
}
|
|
79
|
+
return parsed as BncrHistoryShardPayloadEnvelope;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function processBncrHistoryShardSlot(args: {
|
|
83
|
+
shard: BncrHistoryShardRow;
|
|
84
|
+
historyShardQueue: BncrHistoryShardQueue;
|
|
85
|
+
conversationHistories?: BncrConversationHistoryMap;
|
|
86
|
+
outboundReplayCache?: BncrOutboundReplayCache;
|
|
87
|
+
runDispatch: (payload: BncrHistoryShardDispatchPayload) => Promise<void>;
|
|
88
|
+
leaseRenewIntervalMs?: number;
|
|
89
|
+
}) {
|
|
90
|
+
const shardOwner = args.shard.owner ?? getConversationHistorySerialOwner();
|
|
91
|
+
let payload: BncrHistoryShardPayloadEnvelope;
|
|
92
|
+
try {
|
|
93
|
+
payload = parseBncrHistoryShardPayload(args.shard.payloadJson);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
try {
|
|
96
|
+
args.historyShardQueue.markHistoryShardFailed(args.shard.id, error, shardOwner);
|
|
97
|
+
} catch (markError) {
|
|
98
|
+
emitBncrLogLine(
|
|
99
|
+
'warn',
|
|
100
|
+
`[bncr] history shard failed mark failed|key=${args.shard.historyKey}|shard=${args.shard.id}|reason=${String(markError)}`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
await runBncrHistoryShardWithLeaseRenewal({
|
|
106
|
+
shardId: args.shard.id,
|
|
107
|
+
historyShardQueue: args.historyShardQueue,
|
|
108
|
+
intervalMs: args.leaseRenewIntervalMs,
|
|
109
|
+
owner: shardOwner,
|
|
110
|
+
task: () =>
|
|
111
|
+
runConversationHistorySerial(
|
|
112
|
+
args.shard.historyKey,
|
|
113
|
+
async (handle) => {
|
|
114
|
+
handle.phase('snapshot');
|
|
115
|
+
if (
|
|
116
|
+
args.historyShardQueue.markHistoryShardProcessing(args.shard.id, shardOwner) === false
|
|
117
|
+
) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`history shard activation lost|key=${args.shard.historyKey}|shard=${args.shard.id}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
await args.runDispatch({
|
|
124
|
+
...payload.dispatch,
|
|
125
|
+
deliveryId: `bncr-history-shard:${args.shard.id}`,
|
|
126
|
+
});
|
|
127
|
+
} catch (error) {
|
|
128
|
+
try {
|
|
129
|
+
args.historyShardQueue.markHistoryShardFailed(args.shard.id, error, shardOwner);
|
|
130
|
+
} catch (markError) {
|
|
131
|
+
emitBncrLogLine(
|
|
132
|
+
'warn',
|
|
133
|
+
`[bncr] history shard failed mark failed|key=${args.shard.historyKey}|shard=${args.shard.id}|reason=${String(markError)}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
handle.phase('upload_end');
|
|
139
|
+
try {
|
|
140
|
+
args.historyShardQueue.markHistoryShardCompleted(args.shard.id, shardOwner);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
// Completion marking is observability only; cleanup still deletes the
|
|
143
|
+
// shard-owned rows after the upload has settled.
|
|
144
|
+
emitBncrLogLine(
|
|
145
|
+
'warn',
|
|
146
|
+
`[bncr] history shard complete mark failed|key=${args.shard.historyKey}|shard=${args.shard.id}|reason=${String(error)}`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
handle.phase('cache_delete_start');
|
|
150
|
+
if (args.conversationHistories) {
|
|
151
|
+
removeBncrConversationHistoryMessageIds({
|
|
152
|
+
historyMap: args.conversationHistories,
|
|
153
|
+
historyKey: args.shard.historyKey,
|
|
154
|
+
messageIds: payload.messageIds,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (args.outboundReplayCache && payload.bufferKeys.length > 0) {
|
|
158
|
+
removeBncrOutboundReplayMessageIds({
|
|
159
|
+
cache: args.outboundReplayCache,
|
|
160
|
+
parsed: payload.dispatch.parsed,
|
|
161
|
+
accountId: payload.dispatch.resolution.accountId,
|
|
162
|
+
messageIds: payload.messageIds,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
handle.phase('cache_delete_done');
|
|
166
|
+
try {
|
|
167
|
+
args.historyShardQueue.completeHistoryShard(args.shard.id, shardOwner);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
// A failed cleanup is recovered from the DB on restart. Do not retry
|
|
170
|
+
// the same shard here because the upload itself already succeeded.
|
|
171
|
+
emitBncrLogLine(
|
|
172
|
+
'warn',
|
|
173
|
+
`[bncr] history shard complete failed|key=${args.shard.historyKey}|shard=${args.shard.id}|reason=${String(error)}`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
{ owner: shardOwner },
|
|
178
|
+
),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
@@ -11,12 +11,24 @@ export type ParseBncrNativeCommandOptions = {
|
|
|
11
11
|
allowBareSessionReset?: boolean;
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
+
export function isBncrStopCommandText(rawBody: string): boolean {
|
|
15
|
+
const raw = String(rawBody || '')
|
|
16
|
+
.trim()
|
|
17
|
+
.toLowerCase();
|
|
18
|
+
return raw === '/stop';
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
export type NativeVerboseCommand = {
|
|
15
22
|
handled: true;
|
|
16
23
|
verboseLevel?: 'on' | 'off' | 'full';
|
|
17
24
|
text: string;
|
|
18
25
|
};
|
|
19
26
|
|
|
27
|
+
export type NativeHelpCommandOptions = {
|
|
28
|
+
isAdmin?: boolean;
|
|
29
|
+
peerKind?: 'direct' | 'group';
|
|
30
|
+
};
|
|
31
|
+
|
|
20
32
|
export type NativeHelpCommand = {
|
|
21
33
|
handled: true;
|
|
22
34
|
text: string;
|
|
@@ -38,32 +50,6 @@ export type NativeSessionResetCommand = {
|
|
|
38
50
|
text: string;
|
|
39
51
|
};
|
|
40
52
|
|
|
41
|
-
const BNCR_HELP_TEXT = [
|
|
42
|
-
'🦞 Bncr command usage',
|
|
43
|
-
'',
|
|
44
|
-
'📌 Bncr builtins',
|
|
45
|
-
' • /bncr whoami',
|
|
46
|
-
' • /bncr verbose on|off|full',
|
|
47
|
-
'',
|
|
48
|
-
'🛡 Scene approval',
|
|
49
|
-
' • /bncr allow [<SceneId>]',
|
|
50
|
-
' • /bncr deny [<SceneId>]',
|
|
51
|
-
' • /bncr bind <agentId> [<SceneId>]',
|
|
52
|
-
' • /bncr mode help',
|
|
53
|
-
' • /bncr mode <admin|mention|hybrid|all|clear> [<SceneId>]',
|
|
54
|
-
' • /bncr revoke [<SceneId>]',
|
|
55
|
-
' • /bncr list pending [filters...]',
|
|
56
|
-
' • /bncr list scenes [filters...]',
|
|
57
|
-
'',
|
|
58
|
-
'📋 Group history',
|
|
59
|
-
' • /bncr history-help',
|
|
60
|
-
' • /bncr history-limit [<number>|clear] [<SceneId>]',
|
|
61
|
-
' • /bncr history-force on|off|clear [<SceneId>]',
|
|
62
|
-
'',
|
|
63
|
-
'🌐 Remote media',
|
|
64
|
-
' • /bncr download-media on|off|clear|default on|off [<SceneId>]',
|
|
65
|
-
].join('\n');
|
|
66
|
-
|
|
67
53
|
const BNCR_NATIVE_COMMANDS = new Set([
|
|
68
54
|
'help',
|
|
69
55
|
'new',
|
|
@@ -120,7 +106,7 @@ export function parseBncrNativeCommand(
|
|
|
120
106
|
return { command: 'whoami', raw, body: '/whoami', argsText: '' };
|
|
121
107
|
}
|
|
122
108
|
if (!raw.startsWith('/bncr')) return null;
|
|
123
|
-
const match = raw.match(/^\/bncr(
|
|
109
|
+
const match = raw.match(/^\/bncr(?:\s+([^\s]+)(?:\s+([\s\S]*))?)?$/i);
|
|
124
110
|
if (!match) return null;
|
|
125
111
|
|
|
126
112
|
const command = String(match[1] || 'help')
|
|
@@ -142,7 +128,7 @@ export function parseBncrUnsupportedDirectCommand(text: string): {
|
|
|
142
128
|
const raw = String(text || '').trim();
|
|
143
129
|
if (!raw.startsWith('/')) return null;
|
|
144
130
|
|
|
145
|
-
const bareMatch = raw.match(/^\/([A-Za-z0-9_]+)(
|
|
131
|
+
const bareMatch = raw.match(/^\/([A-Za-z0-9_]+)(?:\s+.*)?$/);
|
|
146
132
|
if (!bareMatch) return null;
|
|
147
133
|
const bareCommand = String(bareMatch[1] || '')
|
|
148
134
|
.trim()
|
|
@@ -150,7 +136,7 @@ export function parseBncrUnsupportedDirectCommand(text: string): {
|
|
|
150
136
|
if (!bareCommand) return null;
|
|
151
137
|
|
|
152
138
|
if (bareCommand === 'bncr') {
|
|
153
|
-
const subMatch = raw.match(/^\/bncr(
|
|
139
|
+
const subMatch = raw.match(/^\/bncr(?:\s+([A-Za-z0-9_-]+))?/i);
|
|
154
140
|
const subCommand =
|
|
155
141
|
String(subMatch?.[1] || '')
|
|
156
142
|
.trim()
|
|
@@ -163,9 +149,77 @@ export function parseBncrUnsupportedDirectCommand(text: string): {
|
|
|
163
149
|
return { command: bareCommand, raw };
|
|
164
150
|
}
|
|
165
151
|
|
|
166
|
-
|
|
152
|
+
const BNCR_HELP_SECTIONS = [
|
|
153
|
+
{
|
|
154
|
+
title: '📌 Bncr builtins',
|
|
155
|
+
audience: 'all',
|
|
156
|
+
commands: [
|
|
157
|
+
{ label: '/bncr whoami', scopes: ['admin', 'direct', 'group'] },
|
|
158
|
+
{ label: '/bncr status', scopes: ['admin', 'direct', 'group'] },
|
|
159
|
+
{ label: '/bncr new', scopes: ['admin', 'direct'] },
|
|
160
|
+
{ label: '/bncr reset', scopes: ['admin', 'direct'] },
|
|
161
|
+
{ label: '/bncr verbose on|off|full', scopes: ['admin'] },
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
title: '🛡 Scene approval',
|
|
166
|
+
audience: 'admin',
|
|
167
|
+
commands: [
|
|
168
|
+
{ label: '/bncr allow [<SceneId>]', scopes: ['admin'] },
|
|
169
|
+
{ label: '/bncr deny [<SceneId>]', scopes: ['admin'] },
|
|
170
|
+
{ label: '/bncr bind <agentId> [<SceneId>]', scopes: ['admin'] },
|
|
171
|
+
{ label: '/bncr mode help', scopes: ['admin'] },
|
|
172
|
+
{
|
|
173
|
+
label: '/bncr mode <admin|mention|hybrid|all|clear> [<SceneId>]',
|
|
174
|
+
scopes: ['admin'],
|
|
175
|
+
},
|
|
176
|
+
{ label: '/bncr revoke [<SceneId>]', scopes: ['admin'] },
|
|
177
|
+
{ label: '/bncr list pending [filters...]', scopes: ['admin'] },
|
|
178
|
+
{ label: '/bncr list scenes [filters...]', scopes: ['admin'] },
|
|
179
|
+
],
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
title: '📋 Conversation history',
|
|
183
|
+
audience: 'admin',
|
|
184
|
+
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'] },
|
|
188
|
+
],
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
title: '🌐 Remote media',
|
|
192
|
+
audience: 'admin',
|
|
193
|
+
commands: [
|
|
194
|
+
{
|
|
195
|
+
label: '/bncr download-media on|off|clear|default on|off [<SceneId>]',
|
|
196
|
+
scopes: ['admin'],
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
},
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
export function resolveBncrNativeHelpCommand(
|
|
203
|
+
command: NativeCommand,
|
|
204
|
+
options?: NativeHelpCommandOptions,
|
|
205
|
+
): NativeHelpCommand | null {
|
|
167
206
|
if (command.command !== 'help') return null;
|
|
168
|
-
|
|
207
|
+
|
|
208
|
+
const isAdmin = options?.isAdmin === true;
|
|
209
|
+
const isGroup = options?.peerKind === 'group';
|
|
210
|
+
const audience = isAdmin ? 'admin' : isGroup ? 'group' : 'direct';
|
|
211
|
+
const sections: string[] = ['🦞 Bncr command usage', ''];
|
|
212
|
+
|
|
213
|
+
for (const section of BNCR_HELP_SECTIONS) {
|
|
214
|
+
if (section.audience !== 'all' && section.audience !== audience) continue;
|
|
215
|
+
const visible = section.commands.filter((item) => item.scopes.includes(audience));
|
|
216
|
+
if (visible.length === 0) continue;
|
|
217
|
+
sections.push(section.title);
|
|
218
|
+
for (const item of visible) sections.push(` • ${item.label}`);
|
|
219
|
+
sections.push('');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return { handled: true, text: sections.join('\n') };
|
|
169
223
|
}
|
|
170
224
|
|
|
171
225
|
export function resolveBncrNativeWhoamiCommand(args: {
|
|
@@ -214,16 +268,18 @@ export function resolveBncrNativeStatusCommand(args: {
|
|
|
214
268
|
|
|
215
269
|
export function resolveBncrNativeSessionResetCommand(args: {
|
|
216
270
|
command: NativeCommand;
|
|
271
|
+
peerKind: 'direct' | 'group';
|
|
217
272
|
}): NativeSessionResetCommand | null {
|
|
218
273
|
if (args.command.command !== 'new' && args.command.command !== 'reset') return null;
|
|
219
274
|
const reason = args.command.command === 'new' ? 'new' : 'reset';
|
|
275
|
+
const scope = args.peerKind === 'group' ? 'this group chat' : 'this private chat';
|
|
220
276
|
return {
|
|
221
277
|
handled: true,
|
|
222
278
|
reason,
|
|
223
279
|
text:
|
|
224
280
|
reason === 'new'
|
|
225
|
-
?
|
|
226
|
-
:
|
|
281
|
+
? `Started a new session for ${scope}.`
|
|
282
|
+
: `Reset the current session for ${scope}.`,
|
|
227
283
|
};
|
|
228
284
|
}
|
|
229
285
|
|
|
@@ -4,6 +4,15 @@ import type {
|
|
|
4
4
|
BncrOutboundReplayStatus,
|
|
5
5
|
OutboxEntry,
|
|
6
6
|
} from '../../core/types.ts';
|
|
7
|
+
import {
|
|
8
|
+
type BncrConversationHistoryMap,
|
|
9
|
+
buildBncrBotReplyMessageId,
|
|
10
|
+
buildBncrConversationHistoryKey,
|
|
11
|
+
buildBncrConversationHistoryKeyFromRoute,
|
|
12
|
+
readConversationHistoryVersion,
|
|
13
|
+
recordBncrBotReply,
|
|
14
|
+
resolveBncrHistoryLimit,
|
|
15
|
+
} from './conversation-history.ts';
|
|
7
16
|
import type { ParsedInbound } from './dispatch-prep.ts';
|
|
8
17
|
|
|
9
18
|
export type {
|
|
@@ -83,19 +92,21 @@ function recordOutboundReplayEntry(args: {
|
|
|
83
92
|
cache: BncrOutboundReplayCache;
|
|
84
93
|
cacheKey: string;
|
|
85
94
|
entry: BncrOutboundReplayEntry;
|
|
86
|
-
|
|
95
|
+
historyLimit?: number;
|
|
96
|
+
}): boolean {
|
|
87
97
|
const msgId = String(args.entry.messageId || '').trim();
|
|
88
98
|
const existing = args.cache.get(args.cacheKey) || [];
|
|
89
|
-
if (msgId && existing.some((entry) => entry.messageId === msgId)) return;
|
|
99
|
+
if (msgId && existing.some((entry) => entry.messageId === msgId)) return false;
|
|
90
100
|
|
|
91
|
-
const next = [...existing, cloneBncrOutboundReplayEntry(args.entry)]
|
|
92
|
-
(a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0)
|
|
93
|
-
|
|
101
|
+
const next = [...existing, cloneBncrOutboundReplayEntry(args.entry)]
|
|
102
|
+
.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0))
|
|
103
|
+
.slice(-resolveBncrHistoryLimit(args.historyLimit));
|
|
94
104
|
|
|
95
105
|
if (args.cache.has(args.cacheKey)) {
|
|
96
106
|
args.cache.delete(args.cacheKey);
|
|
97
107
|
}
|
|
98
108
|
args.cache.set(args.cacheKey, next);
|
|
109
|
+
return true;
|
|
99
110
|
}
|
|
100
111
|
|
|
101
112
|
function inferMediaKind(args: {
|
|
@@ -150,20 +161,28 @@ function isAppMsg(type: string | undefined, msg: string): boolean {
|
|
|
150
161
|
);
|
|
151
162
|
}
|
|
152
163
|
|
|
164
|
+
export type BncrOutboundReplayRecordResult = {
|
|
165
|
+
recorded: boolean;
|
|
166
|
+
historyOverflow: boolean;
|
|
167
|
+
historyVersion?: number;
|
|
168
|
+
};
|
|
169
|
+
|
|
153
170
|
export function recordBncrOutboundReplay(args: {
|
|
154
171
|
cache: BncrOutboundReplayCache;
|
|
172
|
+
conversationHistories?: BncrConversationHistoryMap;
|
|
173
|
+
historyLimit?: number;
|
|
155
174
|
entry: OutboxEntry;
|
|
156
175
|
sender: string;
|
|
157
176
|
senderId?: string;
|
|
158
177
|
status?: BncrOutboundReplayStatus;
|
|
159
|
-
}) {
|
|
178
|
+
}): BncrOutboundReplayRecordResult {
|
|
160
179
|
const cacheKey = buildBncrOutboundReplayKeyFromRoute({
|
|
161
180
|
accountId: args.entry.accountId,
|
|
162
181
|
platform: args.entry.route?.platform,
|
|
163
182
|
groupId: args.entry.route?.groupId,
|
|
164
183
|
userId: args.entry.route?.userId,
|
|
165
184
|
});
|
|
166
|
-
if (!cacheKey) return;
|
|
185
|
+
if (!cacheKey) return { recorded: false, historyOverflow: false };
|
|
167
186
|
|
|
168
187
|
const message =
|
|
169
188
|
args.entry.payload?.message && typeof args.entry.payload.message === 'object'
|
|
@@ -178,17 +197,114 @@ export function recordBncrOutboundReplay(args: {
|
|
|
178
197
|
const body = isAppMsg(type, rawBody)
|
|
179
198
|
? buildMediaBody('document')
|
|
180
199
|
: normalizeTextBody(rawBody) || (isMedia ? buildMediaBody(kind) : '');
|
|
181
|
-
if (!body) return;
|
|
200
|
+
if (!body) return { recorded: false, historyOverflow: false };
|
|
201
|
+
const recordTimestamp = args.entry.lastPushAt ?? Date.now();
|
|
202
|
+
const historyKey = buildBncrConversationHistoryKeyFromRoute({
|
|
203
|
+
platform: args.entry.route?.platform,
|
|
204
|
+
groupId: args.entry.route?.groupId,
|
|
205
|
+
userId: args.entry.route?.userId,
|
|
206
|
+
});
|
|
207
|
+
const messageId = historyKey
|
|
208
|
+
? buildBncrBotReplyMessageId({
|
|
209
|
+
historyKey,
|
|
210
|
+
sender: args.sender,
|
|
211
|
+
senderId: args.senderId,
|
|
212
|
+
body,
|
|
213
|
+
timestamp: recordTimestamp,
|
|
214
|
+
messageId: args.entry.messageId,
|
|
215
|
+
media: mediaUrl
|
|
216
|
+
? [
|
|
217
|
+
{
|
|
218
|
+
path: mediaUrl,
|
|
219
|
+
kind,
|
|
220
|
+
messageId: args.entry.messageId,
|
|
221
|
+
},
|
|
222
|
+
]
|
|
223
|
+
: undefined,
|
|
224
|
+
})
|
|
225
|
+
: undefined;
|
|
226
|
+
let historyOverflow = false;
|
|
227
|
+
let historyVersion: number | undefined;
|
|
182
228
|
|
|
183
|
-
|
|
229
|
+
if (args.conversationHistories) {
|
|
230
|
+
if (historyKey) {
|
|
231
|
+
recordBncrBotReply({
|
|
232
|
+
historyMap: args.conversationHistories,
|
|
233
|
+
historyKey,
|
|
234
|
+
sender: args.sender,
|
|
235
|
+
senderId: args.senderId,
|
|
236
|
+
body,
|
|
237
|
+
timestamp: recordTimestamp,
|
|
238
|
+
messageId,
|
|
239
|
+
historyLimit: args.historyLimit,
|
|
240
|
+
...(mediaUrl
|
|
241
|
+
? {
|
|
242
|
+
media: [
|
|
243
|
+
{
|
|
244
|
+
path: mediaUrl,
|
|
245
|
+
kind,
|
|
246
|
+
messageId,
|
|
247
|
+
},
|
|
248
|
+
],
|
|
249
|
+
}
|
|
250
|
+
: {}),
|
|
251
|
+
});
|
|
252
|
+
const entries = args.conversationHistories.get(historyKey) || [];
|
|
253
|
+
const limit = resolveBncrHistoryLimit(args.historyLimit);
|
|
254
|
+
// Overflow is decided by the current active window after the outbound
|
|
255
|
+
// event, not by whether this call newly wrote a row. A restored or
|
|
256
|
+
// repeated ack must still be able to force a flush.
|
|
257
|
+
historyOverflow = entries.length >= limit;
|
|
258
|
+
if (historyOverflow) {
|
|
259
|
+
historyVersion = readConversationHistoryVersion(args.conversationHistories, historyKey);
|
|
260
|
+
}
|
|
261
|
+
const cacheRecorded = recordOutboundReplayEntry({
|
|
262
|
+
cache: args.cache,
|
|
263
|
+
cacheKey,
|
|
264
|
+
historyLimit: args.historyLimit,
|
|
265
|
+
entry: {
|
|
266
|
+
sender: args.sender,
|
|
267
|
+
...(args.senderId ? { senderId: args.senderId } : {}),
|
|
268
|
+
body,
|
|
269
|
+
timestamp: recordTimestamp,
|
|
270
|
+
messageId,
|
|
271
|
+
accountId: args.entry.accountId,
|
|
272
|
+
sessionKey: args.entry.sessionKey,
|
|
273
|
+
route: { ...args.entry.route },
|
|
274
|
+
...(type ? { type } : {}),
|
|
275
|
+
...(mediaUrl ? { mediaUrl } : {}),
|
|
276
|
+
createdAt: args.entry.createdAt,
|
|
277
|
+
...(args.status ? { status: args.status } : {}),
|
|
278
|
+
...(mediaUrl
|
|
279
|
+
? {
|
|
280
|
+
media: [
|
|
281
|
+
{
|
|
282
|
+
path: mediaUrl,
|
|
283
|
+
kind,
|
|
284
|
+
messageId,
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
}
|
|
288
|
+
: {}),
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
return {
|
|
292
|
+
recorded: cacheRecorded,
|
|
293
|
+
historyOverflow,
|
|
294
|
+
...(historyOverflow && historyVersion !== undefined ? { historyVersion } : {}),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const cacheRecorded = recordOutboundReplayEntry({
|
|
184
299
|
cache: args.cache,
|
|
185
300
|
cacheKey,
|
|
301
|
+
historyLimit: args.historyLimit,
|
|
186
302
|
entry: {
|
|
187
303
|
sender: args.sender,
|
|
188
304
|
...(args.senderId ? { senderId: args.senderId } : {}),
|
|
189
305
|
body,
|
|
190
|
-
timestamp:
|
|
191
|
-
messageId: args.entry.messageId,
|
|
306
|
+
timestamp: recordTimestamp,
|
|
307
|
+
messageId: messageId || args.entry.messageId,
|
|
192
308
|
accountId: args.entry.accountId,
|
|
193
309
|
sessionKey: args.entry.sessionKey,
|
|
194
310
|
route: { ...args.entry.route },
|
|
@@ -202,27 +318,67 @@ export function recordBncrOutboundReplay(args: {
|
|
|
202
318
|
{
|
|
203
319
|
path: mediaUrl,
|
|
204
320
|
kind,
|
|
205
|
-
messageId: args.entry.messageId,
|
|
321
|
+
messageId: messageId || args.entry.messageId,
|
|
206
322
|
},
|
|
207
323
|
],
|
|
208
324
|
}
|
|
209
325
|
: {}),
|
|
210
326
|
},
|
|
211
327
|
});
|
|
328
|
+
return {
|
|
329
|
+
recorded: cacheRecorded,
|
|
330
|
+
historyOverflow: false,
|
|
331
|
+
};
|
|
212
332
|
}
|
|
213
333
|
|
|
214
334
|
export function readBncrOutboundReplaySnapshot(args: {
|
|
215
335
|
cache: BncrOutboundReplayCache;
|
|
336
|
+
conversationHistories?: BncrConversationHistoryMap;
|
|
216
337
|
parsed: ParsedInbound;
|
|
217
338
|
accountId: string;
|
|
218
339
|
excludeMessageId?: string | null;
|
|
219
340
|
}): BncrOutboundReplayEntry[] {
|
|
220
|
-
const cacheKey = buildBncrOutboundReplayKey(args.parsed, args.accountId);
|
|
221
|
-
if (!cacheKey) return [];
|
|
222
341
|
const excludedMessageId = String(args.excludeMessageId || '').trim();
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
.
|
|
342
|
+
const cacheKey = buildBncrOutboundReplayKey(args.parsed, args.accountId);
|
|
343
|
+
const legacyEntries = cacheKey
|
|
344
|
+
? (args.cache.get(cacheKey) || [])
|
|
345
|
+
.filter((entry) => !excludedMessageId || entry.messageId !== excludedMessageId)
|
|
346
|
+
.map(cloneBncrOutboundReplayEntry)
|
|
347
|
+
: [];
|
|
348
|
+
if (!args.conversationHistories) return legacyEntries;
|
|
349
|
+
|
|
350
|
+
const historyKey = buildBncrConversationHistoryKey(args.parsed);
|
|
351
|
+
const historyEntries = historyKey
|
|
352
|
+
? (args.conversationHistories.get(historyKey) || [])
|
|
353
|
+
.filter((entry) => entry.role === 'assistant')
|
|
354
|
+
.filter((entry) => !excludedMessageId || entry.messageId !== excludedMessageId)
|
|
355
|
+
.map((entry) => ({
|
|
356
|
+
sender: entry.sender,
|
|
357
|
+
...(entry.senderId ? { senderId: entry.senderId } : {}),
|
|
358
|
+
body: entry.body,
|
|
359
|
+
...(typeof entry.timestamp === 'number' ? { timestamp: entry.timestamp } : {}),
|
|
360
|
+
...(entry.messageId ? { messageId: entry.messageId } : {}),
|
|
361
|
+
...(Array.isArray(entry.media)
|
|
362
|
+
? {
|
|
363
|
+
media: entry.media.map((item) => ({
|
|
364
|
+
path: item.path,
|
|
365
|
+
contentType: item.contentType,
|
|
366
|
+
kind: item.kind,
|
|
367
|
+
messageId: item.messageId,
|
|
368
|
+
})),
|
|
369
|
+
}
|
|
370
|
+
: {}),
|
|
371
|
+
}))
|
|
372
|
+
: [];
|
|
373
|
+
const knownMessageIds = new Set(
|
|
374
|
+
historyEntries
|
|
375
|
+
.map((entry) => entry.messageId)
|
|
376
|
+
.filter((messageId): messageId is string => Boolean(messageId)),
|
|
377
|
+
);
|
|
378
|
+
return [
|
|
379
|
+
...historyEntries,
|
|
380
|
+
...legacyEntries.filter((entry) => !entry.messageId || !knownMessageIds.has(entry.messageId)),
|
|
381
|
+
].sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
|
|
226
382
|
}
|
|
227
383
|
|
|
228
384
|
export function clearBncrOutboundReplay(args: {
|
|
@@ -234,3 +390,32 @@ export function clearBncrOutboundReplay(args: {
|
|
|
234
390
|
if (!cacheKey) return;
|
|
235
391
|
args.cache.delete(cacheKey);
|
|
236
392
|
}
|
|
393
|
+
|
|
394
|
+
export function removeBncrOutboundReplayMessageIds(args: {
|
|
395
|
+
cache: BncrOutboundReplayCache;
|
|
396
|
+
parsed: ParsedInbound;
|
|
397
|
+
accountId: string;
|
|
398
|
+
messageIds: ReadonlyArray<string | undefined | null>;
|
|
399
|
+
}): number {
|
|
400
|
+
const cacheKey = buildBncrOutboundReplayKey(args.parsed, args.accountId);
|
|
401
|
+
if (!cacheKey) return 0;
|
|
402
|
+
const messageIds = new Set(
|
|
403
|
+
args.messageIds
|
|
404
|
+
.map((messageId) => String(messageId || '').trim())
|
|
405
|
+
.filter((messageId) => Boolean(messageId)),
|
|
406
|
+
);
|
|
407
|
+
if (messageIds.size === 0) return 0;
|
|
408
|
+
const current = args.cache.get(cacheKey) || [];
|
|
409
|
+
if (current.length === 0) return 0;
|
|
410
|
+
const next = current.filter(
|
|
411
|
+
(entry) =>
|
|
412
|
+
!String(entry.messageId || '').trim() || !messageIds.has(String(entry.messageId).trim()),
|
|
413
|
+
);
|
|
414
|
+
if (next.length === current.length) return 0;
|
|
415
|
+
if (next.length === 0) {
|
|
416
|
+
args.cache.delete(cacheKey);
|
|
417
|
+
} else {
|
|
418
|
+
args.cache.set(cacheKey, next);
|
|
419
|
+
}
|
|
420
|
+
return current.length - next.length;
|
|
421
|
+
}
|