@xmoxmo/bncr 0.6.2 → 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 +3 -0
- package/scripts/selfcheck.mjs +3 -0
- package/src/channel.ts +134 -7
- package/src/messaging/inbound/commands.ts +62 -26
- package/src/messaging/inbound/conversation-history-serial.ts +258 -0
- package/src/messaging/inbound/conversation-history.ts +174 -55
- package/src/messaging/inbound/dispatch.ts +557 -137
- 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 +128 -19
- package/src/messaging/inbound/reply-dispatch.ts +7 -6
- package/src/messaging/inbound/scene-admin.ts +17 -12
- package/src/plugin/bridge-lifecycle.ts +8 -0
- package/src/plugin/bridge-outbox-facade.ts +6 -1
- package/src/plugin/channel-runtime-constants.ts +2 -0
- package/src/plugin/outbox-drain-post-push.ts +3 -0
- package/src/plugin/sqlite-state.ts +2109 -0
- package/src/plugin/state-store.ts +704 -46
- package/src/plugin/state-transient-runtime-group.ts +14 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { emitBncrLogLine } from '../../core/logging.ts';
|
|
2
|
+
|
|
3
|
+
export type ConversationHistorySerialPhase =
|
|
4
|
+
| 'queued'
|
|
5
|
+
| 'running'
|
|
6
|
+
| 'snapshot'
|
|
7
|
+
| 'shard_created'
|
|
8
|
+
| 'upload_start'
|
|
9
|
+
| 'upload_end'
|
|
10
|
+
| 'cache_delete_start'
|
|
11
|
+
| 'cache_delete_done'
|
|
12
|
+
| 'lock_clear_start'
|
|
13
|
+
| 'lock_clear_done'
|
|
14
|
+
| 'abandoned';
|
|
15
|
+
|
|
16
|
+
export type ConversationHistorySerialState = {
|
|
17
|
+
historyKey: string;
|
|
18
|
+
owner?: string;
|
|
19
|
+
phase: ConversationHistorySerialPhase;
|
|
20
|
+
updatedAt: number;
|
|
21
|
+
snapshotMessageIds: string[];
|
|
22
|
+
cacheKey?: string;
|
|
23
|
+
uploadCompleted: boolean;
|
|
24
|
+
abandoned: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ConversationHistorySerialHandle = {
|
|
28
|
+
phase: (
|
|
29
|
+
phase: ConversationHistorySerialPhase,
|
|
30
|
+
data?: { snapshotMessageIds?: string[]; cacheKey?: string },
|
|
31
|
+
) => void;
|
|
32
|
+
setCleanup: (cleanup: () => void) => void;
|
|
33
|
+
isAbandoned: () => boolean;
|
|
34
|
+
owner: () => string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type BncrConversationHistorySerialEntry = {
|
|
38
|
+
chain: Promise<void>;
|
|
39
|
+
abort: (reason?: unknown) => void;
|
|
40
|
+
state: ConversationHistorySerialState;
|
|
41
|
+
cleanup?: () => void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const bncrConversationHistoryChains = new Map<string, BncrConversationHistorySerialEntry[]>();
|
|
45
|
+
let bncrConversationHistorySerialOwner = 'default';
|
|
46
|
+
|
|
47
|
+
export function setConversationHistorySerialOwner(owner: string): void {
|
|
48
|
+
bncrConversationHistorySerialOwner = String(owner || '').trim() || 'default';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getConversationHistorySerialOwner(): string {
|
|
52
|
+
return bncrConversationHistorySerialOwner;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function createConversationHistorySerialHandle(entry?: BncrConversationHistorySerialEntry) {
|
|
56
|
+
return {
|
|
57
|
+
phase: (
|
|
58
|
+
phase: ConversationHistorySerialPhase,
|
|
59
|
+
data?: { snapshotMessageIds?: string[]; cacheKey?: string },
|
|
60
|
+
) => {
|
|
61
|
+
if (!entry) return;
|
|
62
|
+
entry.state.phase = phase;
|
|
63
|
+
entry.state.updatedAt = Date.now();
|
|
64
|
+
if (data?.snapshotMessageIds) {
|
|
65
|
+
entry.state.snapshotMessageIds = data.snapshotMessageIds
|
|
66
|
+
.map((messageId) => String(messageId || '').trim())
|
|
67
|
+
.filter(Boolean);
|
|
68
|
+
}
|
|
69
|
+
if (data?.cacheKey) entry.state.cacheKey = data.cacheKey;
|
|
70
|
+
if (phase === 'upload_end') entry.state.uploadCompleted = true;
|
|
71
|
+
},
|
|
72
|
+
setCleanup: (cleanup: () => void) => {
|
|
73
|
+
if (entry) entry.cleanup = cleanup;
|
|
74
|
+
},
|
|
75
|
+
isAbandoned: () => {
|
|
76
|
+
if (!entry) return false;
|
|
77
|
+
return (
|
|
78
|
+
entry.state.abandoned === true ||
|
|
79
|
+
(Boolean(entry.state.owner) && entry.state.owner !== bncrConversationHistorySerialOwner)
|
|
80
|
+
);
|
|
81
|
+
},
|
|
82
|
+
owner: () => entry?.state.owner || bncrConversationHistorySerialOwner,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isUploadCompletedPhase(state: ConversationHistorySerialState): boolean {
|
|
87
|
+
return state.uploadCompleted === true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function runConversationHistorySerial<T>(
|
|
91
|
+
historyKey: string,
|
|
92
|
+
task: (handle: ConversationHistorySerialHandle) => Promise<T> | T,
|
|
93
|
+
meta?: { to?: string | null; debugEnabled?: boolean; owner?: string },
|
|
94
|
+
) {
|
|
95
|
+
const key = String(historyKey || '').trim();
|
|
96
|
+
if (!key) return await task(createConversationHistorySerialHandle());
|
|
97
|
+
|
|
98
|
+
const serialEntries = bncrConversationHistoryChains.get(key) || [];
|
|
99
|
+
const previous = serialEntries.at(-1)?.chain || Promise.resolve();
|
|
100
|
+
// A task with no predecessor has already acquired the serial slot even before
|
|
101
|
+
// its first microtask runs. Stale-lock cleanup must be able to distinguish
|
|
102
|
+
// that active slot from genuinely queued work behind an earlier lock.
|
|
103
|
+
const hasPredecessor = serialEntries.length > 0;
|
|
104
|
+
let release!: () => void;
|
|
105
|
+
let abort!: (reason?: unknown) => void;
|
|
106
|
+
const current = new Promise<void>((resolve, reject) => {
|
|
107
|
+
release = resolve;
|
|
108
|
+
abort = reject;
|
|
109
|
+
});
|
|
110
|
+
const chain = previous.then(
|
|
111
|
+
() => current,
|
|
112
|
+
() => current,
|
|
113
|
+
);
|
|
114
|
+
const serialEntry: BncrConversationHistorySerialEntry = {
|
|
115
|
+
chain,
|
|
116
|
+
abort,
|
|
117
|
+
state: {
|
|
118
|
+
historyKey: key,
|
|
119
|
+
owner: meta?.owner || bncrConversationHistorySerialOwner,
|
|
120
|
+
phase: hasPredecessor ? 'queued' : 'running',
|
|
121
|
+
updatedAt: Date.now(),
|
|
122
|
+
snapshotMessageIds: [],
|
|
123
|
+
uploadCompleted: false,
|
|
124
|
+
abandoned: false,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
const handle = createConversationHistorySerialHandle(serialEntry);
|
|
128
|
+
serialEntries.push(serialEntry);
|
|
129
|
+
bncrConversationHistoryChains.set(key, serialEntries);
|
|
130
|
+
|
|
131
|
+
const debugGate = () => meta?.debugEnabled === true;
|
|
132
|
+
emitBncrLogLine(
|
|
133
|
+
'info',
|
|
134
|
+
`[bncr] conversation-history-serial queued|key=${key}|to=${String(meta?.to || '-')}`,
|
|
135
|
+
{ debugOnly: true },
|
|
136
|
+
debugGate,
|
|
137
|
+
);
|
|
138
|
+
try {
|
|
139
|
+
await previous.catch(() => {});
|
|
140
|
+
emitBncrLogLine(
|
|
141
|
+
'info',
|
|
142
|
+
`[bncr] conversation-history-serial acquired|key=${key}|to=${String(meta?.to || '-')}`,
|
|
143
|
+
{ debugOnly: true },
|
|
144
|
+
debugGate,
|
|
145
|
+
);
|
|
146
|
+
if (serialEntry.state.abandoned) {
|
|
147
|
+
return undefined as T;
|
|
148
|
+
}
|
|
149
|
+
serialEntry.state.phase = 'running';
|
|
150
|
+
serialEntry.state.updatedAt = Date.now();
|
|
151
|
+
return await task(handle);
|
|
152
|
+
} finally {
|
|
153
|
+
emitBncrLogLine(
|
|
154
|
+
'info',
|
|
155
|
+
`[bncr] conversation-history-serial lock-clear-start|key=${key}|phase=${serialEntry.state.phase}`,
|
|
156
|
+
{ debugOnly: true },
|
|
157
|
+
debugGate,
|
|
158
|
+
);
|
|
159
|
+
serialEntry.state.phase = 'lock_clear_start';
|
|
160
|
+
serialEntry.state.updatedAt = Date.now();
|
|
161
|
+
release();
|
|
162
|
+
serialEntry.state.phase = 'lock_clear_done';
|
|
163
|
+
serialEntry.state.updatedAt = Date.now();
|
|
164
|
+
const currentEntries = bncrConversationHistoryChains.get(key);
|
|
165
|
+
if (currentEntries) {
|
|
166
|
+
const entryIndex = currentEntries.indexOf(serialEntry);
|
|
167
|
+
if (entryIndex >= 0) currentEntries.splice(entryIndex, 1);
|
|
168
|
+
if (currentEntries.length === 0) bncrConversationHistoryChains.delete(key);
|
|
169
|
+
}
|
|
170
|
+
emitBncrLogLine(
|
|
171
|
+
'info',
|
|
172
|
+
`[bncr] conversation-history-serial lock-clear-done|key=${key}|phase=${serialEntry.state.phase}`,
|
|
173
|
+
{ debugOnly: true },
|
|
174
|
+
debugGate,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function clearConversationHistorySerialLocks(reason = 'runtime-restart', owner?: string) {
|
|
180
|
+
bncrConversationHistorySerialOwner =
|
|
181
|
+
owner || `${reason}:${Date.now()}:${Math.random().toString(16).slice(2, 8)}`;
|
|
182
|
+
const staleEntries = Array.from(bncrConversationHistoryChains.values()).flat();
|
|
183
|
+
const staleCount = staleEntries.length;
|
|
184
|
+
for (const entry of staleEntries) {
|
|
185
|
+
if (entry.state.phase === 'queued') {
|
|
186
|
+
// Queued work is intentionally preserved after a hot rebuild; give it
|
|
187
|
+
// the new instance owner so it is not treated as stale when it runs.
|
|
188
|
+
entry.state.owner = bncrConversationHistorySerialOwner;
|
|
189
|
+
}
|
|
190
|
+
// Upload-completed locks can safely finish snapshot cleanup before being abandoned.
|
|
191
|
+
// Locks still uploading cannot prove delivery, so their local cache is preserved.
|
|
192
|
+
if (isUploadCompletedPhase(entry.state) && entry.cleanup) {
|
|
193
|
+
try {
|
|
194
|
+
entry.cleanup();
|
|
195
|
+
} catch (err) {
|
|
196
|
+
emitBncrLogLine(
|
|
197
|
+
'warn',
|
|
198
|
+
`[bncr] conversation-history-serial stale cleanup failed|key=${entry.state.historyKey}|reason=${String(err)}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Started locks are abandoned but remain in the chain as a barrier. A hot
|
|
203
|
+
// rebuild must not let new tasks run while the old upload still owns state;
|
|
204
|
+
// once the old task releases its lock it leaves the map and new work runs.
|
|
205
|
+
if (entry.state.phase !== 'queued') {
|
|
206
|
+
entry.state.abandoned = true;
|
|
207
|
+
entry.state.phase = 'abandoned';
|
|
208
|
+
entry.state.updatedAt = Date.now();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// Do not clear the chain map here. Abandoned started entries stay visible to
|
|
212
|
+
// readConversationHistorySerialHistoryKeys so shard recovery and the worker
|
|
213
|
+
// will not race them.
|
|
214
|
+
if (staleCount > 0) {
|
|
215
|
+
emitBncrLogLine(
|
|
216
|
+
'warn',
|
|
217
|
+
`[bncr] conversation-history-serial settled stale locks|count=${staleCount}|reason=${reason}`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return staleCount;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function readConversationHistorySerialStates(): readonly ConversationHistorySerialState[] {
|
|
224
|
+
return Array.from(bncrConversationHistoryChains.values()).flatMap((entries) =>
|
|
225
|
+
entries.map((entry) => ({
|
|
226
|
+
historyKey: entry.state.historyKey,
|
|
227
|
+
...(entry.state.owner ? { owner: entry.state.owner } : {}),
|
|
228
|
+
phase: entry.state.phase,
|
|
229
|
+
updatedAt: entry.state.updatedAt,
|
|
230
|
+
snapshotMessageIds: [...entry.state.snapshotMessageIds],
|
|
231
|
+
...(entry.state.cacheKey ? { cacheKey: entry.state.cacheKey } : {}),
|
|
232
|
+
uploadCompleted: entry.state.uploadCompleted,
|
|
233
|
+
abandoned: entry.state.abandoned,
|
|
234
|
+
})),
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function readConversationHistorySerialHistoryKeys(): string[] {
|
|
239
|
+
return Array.from(bncrConversationHistoryChains.keys()).filter(
|
|
240
|
+
(historyKey) => (bncrConversationHistoryChains.get(historyKey)?.length ?? 0) > 0,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function resetConversationHistorySerialForTest() {
|
|
245
|
+
setConversationHistorySerialOwner('default');
|
|
246
|
+
clearConversationHistorySerialLocks('test');
|
|
247
|
+
// Tests must never inherit queued chains from an earlier test. Runtime
|
|
248
|
+
// restarts deliberately keep queued work in the serial chain, so only the
|
|
249
|
+
// test helper fully abandons those entries as well.
|
|
250
|
+
for (const entry of Array.from(bncrConversationHistoryChains.values()).flat()) {
|
|
251
|
+
entry.state.abandoned = true;
|
|
252
|
+
entry.state.phase = 'abandoned';
|
|
253
|
+
entry.state.updatedAt = Date.now();
|
|
254
|
+
entry.chain.catch(() => {});
|
|
255
|
+
entry.abort(new Error('conversation-history-serial test reset'));
|
|
256
|
+
}
|
|
257
|
+
bncrConversationHistoryChains.clear();
|
|
258
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import {
|
|
2
3
|
createChannelHistoryWindow,
|
|
3
4
|
DEFAULT_GROUP_HISTORY_LIMIT as DEFAULT_HISTORY_LIMIT,
|
|
@@ -17,12 +18,75 @@ export type BncrConversationHistoryMap = Map<string, BncrHistoryEntry[]>;
|
|
|
17
18
|
|
|
18
19
|
type BncrHistoryMediaKind = NonNullable<HistoryMediaEntry['kind']>;
|
|
19
20
|
|
|
21
|
+
const conversationHistoryVersions = new WeakMap<BncrConversationHistoryMap, Map<string, number>>();
|
|
22
|
+
|
|
23
|
+
function buildSyntheticHistoryMessageId(seed: string): string {
|
|
24
|
+
const digest = createHash('sha1').update(seed).digest('hex').slice(0, 24);
|
|
25
|
+
return `bncr-synthetic:${digest}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildBncrBotReplyMessageId(args: {
|
|
29
|
+
historyKey: string;
|
|
30
|
+
sender: string;
|
|
31
|
+
senderId?: string;
|
|
32
|
+
body: string;
|
|
33
|
+
timestamp?: number;
|
|
34
|
+
messageId?: string;
|
|
35
|
+
media?: HistoryMediaEntry[];
|
|
36
|
+
}): string {
|
|
37
|
+
const provided = String(args.messageId || '').trim();
|
|
38
|
+
if (provided) return provided;
|
|
39
|
+
return buildSyntheticHistoryMessageId(
|
|
40
|
+
JSON.stringify({
|
|
41
|
+
historyKey: args.historyKey,
|
|
42
|
+
sender: args.sender,
|
|
43
|
+
senderId: args.senderId,
|
|
44
|
+
body: args.body,
|
|
45
|
+
timestamp: args.timestamp,
|
|
46
|
+
media: args.media?.map((item) => [item.path, item.contentType, item.kind, item.messageId]),
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function bumpConversationHistoryVersion(
|
|
52
|
+
historyMap: BncrConversationHistoryMap,
|
|
53
|
+
historyKey: string,
|
|
54
|
+
): number {
|
|
55
|
+
let versions = conversationHistoryVersions.get(historyMap);
|
|
56
|
+
if (!versions) {
|
|
57
|
+
versions = new Map();
|
|
58
|
+
conversationHistoryVersions.set(historyMap, versions);
|
|
59
|
+
}
|
|
60
|
+
const next = (versions.get(historyKey) || 0) + 1;
|
|
61
|
+
versions.set(historyKey, next);
|
|
62
|
+
return next;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function readConversationHistoryVersion(
|
|
66
|
+
historyMap: BncrConversationHistoryMap,
|
|
67
|
+
historyKey: string,
|
|
68
|
+
): number {
|
|
69
|
+
return conversationHistoryVersions.get(historyMap)?.get(historyKey) ?? 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function resetConversationHistoryVersions(historyMap: BncrConversationHistoryMap): void {
|
|
73
|
+
conversationHistoryVersions.delete(historyMap);
|
|
74
|
+
}
|
|
75
|
+
|
|
20
76
|
function normalizeTextBody(value: string): string {
|
|
21
77
|
return String(value || '')
|
|
22
78
|
.replace(/\s+/g, ' ')
|
|
23
79
|
.trim();
|
|
24
80
|
}
|
|
25
81
|
|
|
82
|
+
export function resolveBncrHistoryLimit(historyLimit?: number): number {
|
|
83
|
+
if (typeof historyLimit !== 'number' || !Number.isFinite(historyLimit)) {
|
|
84
|
+
return DEFAULT_HISTORY_LIMIT;
|
|
85
|
+
}
|
|
86
|
+
const resolved = Math.floor(historyLimit);
|
|
87
|
+
return resolved >= 2 ? resolved : DEFAULT_HISTORY_LIMIT;
|
|
88
|
+
}
|
|
89
|
+
|
|
26
90
|
export function buildBncrConversationHistoryKey(parsed: ParsedInbound): string | null {
|
|
27
91
|
const platform = String(parsed.platform || '').trim();
|
|
28
92
|
if (!platform) return null;
|
|
@@ -50,6 +114,11 @@ export function buildBncrConversationHistoryKeyFromRoute(args: {
|
|
|
50
114
|
return null;
|
|
51
115
|
}
|
|
52
116
|
|
|
117
|
+
export function resolveBncrConversationHistoryMessageId(parsed: ParsedInbound): string | undefined {
|
|
118
|
+
const msgId = String(parsed.msgId || '').trim();
|
|
119
|
+
return msgId || undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
53
122
|
export function recordBncrPendingConversationText(args: {
|
|
54
123
|
historyMap: BncrConversationHistoryMap;
|
|
55
124
|
parsed: ParsedInbound;
|
|
@@ -58,19 +127,14 @@ export function recordBncrPendingConversationText(args: {
|
|
|
58
127
|
bodyText: string;
|
|
59
128
|
historyLimit?: number;
|
|
60
129
|
}) {
|
|
61
|
-
const limit =
|
|
62
|
-
typeof args.historyLimit === 'number' &&
|
|
63
|
-
Number.isFinite(args.historyLimit) &&
|
|
64
|
-
args.historyLimit >= 0
|
|
65
|
-
? Math.floor(args.historyLimit)
|
|
66
|
-
: DEFAULT_HISTORY_LIMIT;
|
|
130
|
+
const limit = resolveBncrHistoryLimit(args.historyLimit);
|
|
67
131
|
const historyKey = buildBncrConversationHistoryKey(args.parsed);
|
|
68
132
|
const body = normalizeTextBody(args.bodyText);
|
|
69
133
|
if (!historyKey || !body || args.parsed.msgType !== 'text') return;
|
|
70
|
-
const
|
|
71
|
-
if (
|
|
134
|
+
const messageId = resolveBncrConversationHistoryMessageId(args.parsed);
|
|
135
|
+
if (messageId) {
|
|
72
136
|
const existing = args.historyMap.get(historyKey) || [];
|
|
73
|
-
if (existing.some((e) => e.messageId ===
|
|
137
|
+
if (existing.some((e) => e.messageId === messageId)) return;
|
|
74
138
|
}
|
|
75
139
|
createChannelHistoryWindow({ historyMap: args.historyMap }).record({
|
|
76
140
|
historyKey,
|
|
@@ -80,10 +144,11 @@ export function recordBncrPendingConversationText(args: {
|
|
|
80
144
|
senderId: args.senderId,
|
|
81
145
|
body,
|
|
82
146
|
timestamp: Date.now(),
|
|
83
|
-
messageId:
|
|
147
|
+
...(messageId ? { messageId } : {}),
|
|
84
148
|
role: 'user',
|
|
85
149
|
},
|
|
86
150
|
});
|
|
151
|
+
bumpConversationHistoryVersion(args.historyMap, historyKey);
|
|
87
152
|
}
|
|
88
153
|
|
|
89
154
|
function inferBncrHistoryMediaKind(args: {
|
|
@@ -109,7 +174,7 @@ function buildBncrHistoryMediaBody(kind: BncrHistoryMediaKind): string {
|
|
|
109
174
|
return `<media:${kind}>`;
|
|
110
175
|
}
|
|
111
176
|
|
|
112
|
-
export
|
|
177
|
+
export function recordBncrPendingConversationMedia(args: {
|
|
113
178
|
historyMap: BncrConversationHistoryMap;
|
|
114
179
|
parsed: ParsedInbound;
|
|
115
180
|
senderDisplayName: string;
|
|
@@ -123,12 +188,7 @@ export async function recordBncrPendingConversationMedia(args: {
|
|
|
123
188
|
mediaContentType?: string;
|
|
124
189
|
historyLimit?: number;
|
|
125
190
|
}) {
|
|
126
|
-
const limit =
|
|
127
|
-
typeof args.historyLimit === 'number' &&
|
|
128
|
-
Number.isFinite(args.historyLimit) &&
|
|
129
|
-
args.historyLimit >= 0
|
|
130
|
-
? Math.floor(args.historyLimit)
|
|
131
|
-
: DEFAULT_HISTORY_LIMIT;
|
|
191
|
+
const limit = resolveBncrHistoryLimit(args.historyLimit);
|
|
132
192
|
const historyKey = buildBncrConversationHistoryKey(args.parsed);
|
|
133
193
|
if (!historyKey || args.parsed.msgType === 'text') return;
|
|
134
194
|
const normalizedMediaItems = Array.isArray(args.mediaItems) ? args.mediaItems : [];
|
|
@@ -149,10 +209,10 @@ export async function recordBncrPendingConversationMedia(args: {
|
|
|
149
209
|
});
|
|
150
210
|
const body = normalizeTextBody(args.bodyText) || buildBncrHistoryMediaBody(kind);
|
|
151
211
|
if (normalizedMediaItems.length === 0) {
|
|
152
|
-
const
|
|
153
|
-
if (
|
|
212
|
+
const messageId = resolveBncrConversationHistoryMessageId(args.parsed);
|
|
213
|
+
if (messageId) {
|
|
154
214
|
const existing = args.historyMap.get(historyKey) || [];
|
|
155
|
-
if (existing.some((e) => e.messageId ===
|
|
215
|
+
if (existing.some((e) => e.messageId === messageId)) return;
|
|
156
216
|
}
|
|
157
217
|
createChannelHistoryWindow({ historyMap: args.historyMap }).record({
|
|
158
218
|
historyKey,
|
|
@@ -162,16 +222,17 @@ export async function recordBncrPendingConversationMedia(args: {
|
|
|
162
222
|
senderId: args.senderId,
|
|
163
223
|
body,
|
|
164
224
|
timestamp: Date.now(),
|
|
165
|
-
messageId:
|
|
225
|
+
...(messageId ? { messageId } : {}),
|
|
166
226
|
role: 'user',
|
|
167
227
|
},
|
|
168
228
|
});
|
|
229
|
+
bumpConversationHistoryVersion(args.historyMap, historyKey);
|
|
169
230
|
return;
|
|
170
231
|
}
|
|
171
|
-
const
|
|
172
|
-
if (
|
|
232
|
+
const messageId = resolveBncrConversationHistoryMessageId(args.parsed);
|
|
233
|
+
if (messageId) {
|
|
173
234
|
const existing = args.historyMap.get(historyKey) || [];
|
|
174
|
-
if (existing.some((e) => e.messageId ===
|
|
235
|
+
if (existing.some((e) => e.messageId === messageId)) return;
|
|
175
236
|
}
|
|
176
237
|
createChannelHistoryWindow({ historyMap: args.historyMap }).record({
|
|
177
238
|
historyKey,
|
|
@@ -181,7 +242,7 @@ export async function recordBncrPendingConversationMedia(args: {
|
|
|
181
242
|
senderId: args.senderId,
|
|
182
243
|
body,
|
|
183
244
|
timestamp: Date.now(),
|
|
184
|
-
messageId:
|
|
245
|
+
...(messageId ? { messageId } : {}),
|
|
185
246
|
role: 'user',
|
|
186
247
|
media: normalizedMediaItems.map(
|
|
187
248
|
(item) =>
|
|
@@ -189,11 +250,12 @@ export async function recordBncrPendingConversationMedia(args: {
|
|
|
189
250
|
path: item.path,
|
|
190
251
|
contentType: item.contentType || args.mediaContentType,
|
|
191
252
|
kind: item.kind || kind,
|
|
192
|
-
messageId
|
|
253
|
+
messageId,
|
|
193
254
|
}) satisfies HistoryMediaEntry,
|
|
194
255
|
),
|
|
195
256
|
},
|
|
196
257
|
});
|
|
258
|
+
bumpConversationHistoryVersion(args.historyMap, historyKey);
|
|
197
259
|
}
|
|
198
260
|
|
|
199
261
|
export function recordBncrBotReply(args: {
|
|
@@ -206,20 +268,21 @@ export function recordBncrBotReply(args: {
|
|
|
206
268
|
messageId?: string;
|
|
207
269
|
media?: HistoryMediaEntry[];
|
|
208
270
|
historyLimit?: number;
|
|
209
|
-
}) {
|
|
210
|
-
const limit =
|
|
211
|
-
typeof args.historyLimit === 'number' &&
|
|
212
|
-
Number.isFinite(args.historyLimit) &&
|
|
213
|
-
args.historyLimit >= 0
|
|
214
|
-
? Math.floor(args.historyLimit)
|
|
215
|
-
: DEFAULT_HISTORY_LIMIT;
|
|
271
|
+
}): boolean {
|
|
272
|
+
const limit = resolveBncrHistoryLimit(args.historyLimit);
|
|
216
273
|
const body = normalizeTextBody(args.body);
|
|
217
|
-
if (!body) return;
|
|
218
|
-
const msgId =
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
274
|
+
if (!body) return false;
|
|
275
|
+
const msgId = buildBncrBotReplyMessageId({
|
|
276
|
+
historyKey: args.historyKey,
|
|
277
|
+
sender: args.sender,
|
|
278
|
+
senderId: args.senderId,
|
|
279
|
+
body,
|
|
280
|
+
timestamp: args.timestamp,
|
|
281
|
+
messageId: args.messageId,
|
|
282
|
+
media: args.media,
|
|
283
|
+
});
|
|
284
|
+
const existing = args.historyMap.get(args.historyKey) || [];
|
|
285
|
+
if (existing.some((e) => e.messageId === msgId)) return false;
|
|
223
286
|
createChannelHistoryWindow({ historyMap: args.historyMap }).record({
|
|
224
287
|
historyKey: args.historyKey,
|
|
225
288
|
limit,
|
|
@@ -228,11 +291,22 @@ export function recordBncrBotReply(args: {
|
|
|
228
291
|
...(args.senderId ? { senderId: args.senderId } : {}),
|
|
229
292
|
body,
|
|
230
293
|
timestamp: args.timestamp ?? Date.now(),
|
|
231
|
-
|
|
294
|
+
messageId: msgId,
|
|
232
295
|
role: 'assistant',
|
|
233
|
-
...(Array.isArray(args.media) && args.media.length > 0
|
|
296
|
+
...(Array.isArray(args.media) && args.media.length > 0
|
|
297
|
+
? {
|
|
298
|
+
media: args.media.map((item) => ({
|
|
299
|
+
...(item?.path ? { path: item.path } : {}),
|
|
300
|
+
...(item?.contentType ? { contentType: item.contentType } : {}),
|
|
301
|
+
...(item?.kind ? { kind: item.kind } : {}),
|
|
302
|
+
messageId: item?.messageId || msgId,
|
|
303
|
+
})),
|
|
304
|
+
}
|
|
305
|
+
: {}),
|
|
234
306
|
},
|
|
235
307
|
});
|
|
308
|
+
bumpConversationHistoryVersion(args.historyMap, args.historyKey);
|
|
309
|
+
return true;
|
|
236
310
|
}
|
|
237
311
|
|
|
238
312
|
function cloneBncrHistoryEntry(entry: BncrHistoryEntry): BncrHistoryEntry {
|
|
@@ -261,16 +335,41 @@ export function readBncrPendingConversationHistorySnapshot(args: {
|
|
|
261
335
|
parsed: ParsedInbound;
|
|
262
336
|
historyLimit?: number;
|
|
263
337
|
}): BncrHistoryEntry[] {
|
|
264
|
-
const limit =
|
|
265
|
-
typeof args.historyLimit === 'number' &&
|
|
266
|
-
Number.isFinite(args.historyLimit) &&
|
|
267
|
-
args.historyLimit >= 0
|
|
268
|
-
? Math.floor(args.historyLimit)
|
|
269
|
-
: DEFAULT_HISTORY_LIMIT;
|
|
338
|
+
const limit = resolveBncrHistoryLimit(args.historyLimit);
|
|
270
339
|
const historyKey = buildBncrConversationHistoryKey(args.parsed);
|
|
271
340
|
if (!historyKey) return [];
|
|
272
|
-
const
|
|
273
|
-
if (
|
|
341
|
+
const rawEntries = args.historyMap.get(historyKey) || [];
|
|
342
|
+
if (rawEntries.length === 0) return [];
|
|
343
|
+
let normalized = false;
|
|
344
|
+
const entries = rawEntries.map((entry, entryIndex) => {
|
|
345
|
+
const messageId =
|
|
346
|
+
String(entry.messageId || '').trim() ||
|
|
347
|
+
buildSyntheticHistoryMessageId(
|
|
348
|
+
JSON.stringify({
|
|
349
|
+
historyKey,
|
|
350
|
+
entryIndex,
|
|
351
|
+
sender: entry.sender,
|
|
352
|
+
senderId: entry.senderId,
|
|
353
|
+
body: entry.body,
|
|
354
|
+
timestamp: entry.timestamp,
|
|
355
|
+
}),
|
|
356
|
+
);
|
|
357
|
+
if (messageId === entry.messageId) return entry;
|
|
358
|
+
normalized = true;
|
|
359
|
+
return {
|
|
360
|
+
...entry,
|
|
361
|
+
messageId,
|
|
362
|
+
...(Array.isArray(entry.media)
|
|
363
|
+
? {
|
|
364
|
+
media: entry.media.map((item) => ({
|
|
365
|
+
...item,
|
|
366
|
+
messageId: item.messageId || messageId,
|
|
367
|
+
})),
|
|
368
|
+
}
|
|
369
|
+
: {}),
|
|
370
|
+
};
|
|
371
|
+
});
|
|
372
|
+
if (normalized) args.historyMap.set(historyKey, entries);
|
|
274
373
|
return entries
|
|
275
374
|
.slice()
|
|
276
375
|
.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0))
|
|
@@ -316,16 +415,36 @@ export function clearBncrPendingConversationHistory(args: {
|
|
|
316
415
|
parsed: ParsedInbound;
|
|
317
416
|
historyLimit?: number;
|
|
318
417
|
}) {
|
|
319
|
-
const limit =
|
|
320
|
-
typeof args.historyLimit === 'number' &&
|
|
321
|
-
Number.isFinite(args.historyLimit) &&
|
|
322
|
-
args.historyLimit >= 0
|
|
323
|
-
? Math.floor(args.historyLimit)
|
|
324
|
-
: DEFAULT_HISTORY_LIMIT;
|
|
418
|
+
const limit = resolveBncrHistoryLimit(args.historyLimit);
|
|
325
419
|
const historyKey = buildBncrConversationHistoryKey(args.parsed);
|
|
326
420
|
if (!historyKey) return;
|
|
327
421
|
createChannelHistoryWindow({ historyMap: args.historyMap }).clear({
|
|
328
422
|
historyKey,
|
|
329
423
|
limit: limit,
|
|
330
424
|
});
|
|
425
|
+
bumpConversationHistoryVersion(args.historyMap, historyKey);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function removeBncrConversationHistoryMessageIds(args: {
|
|
429
|
+
historyMap: BncrConversationHistoryMap;
|
|
430
|
+
historyKey: string;
|
|
431
|
+
messageIds: ReadonlyArray<string | undefined | null>;
|
|
432
|
+
}): number {
|
|
433
|
+
const messageIds = new Set(
|
|
434
|
+
args.messageIds
|
|
435
|
+
.map((messageId) => String(messageId || '').trim())
|
|
436
|
+
.filter((messageId) => Boolean(messageId)),
|
|
437
|
+
);
|
|
438
|
+
if (messageIds.size === 0) return 0;
|
|
439
|
+
const current = args.historyMap.get(args.historyKey) || [];
|
|
440
|
+
if (current.length === 0) return 0;
|
|
441
|
+
const next = current.filter(
|
|
442
|
+
(entry) =>
|
|
443
|
+
!String(entry.messageId || '').trim() || !messageIds.has(String(entry.messageId).trim()),
|
|
444
|
+
);
|
|
445
|
+
if (next.length === current.length) return 0;
|
|
446
|
+
args.historyMap.set(args.historyKey, next);
|
|
447
|
+
// Snapshot cleanup must not invalidate flushes queued from replies written
|
|
448
|
+
// while the earlier snapshot was uploading.
|
|
449
|
+
return current.length - next.length;
|
|
331
450
|
}
|