@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,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
|
-
'📋 Conversation 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
|
|
|
@@ -6,9 +6,12 @@ import type {
|
|
|
6
6
|
} from '../../core/types.ts';
|
|
7
7
|
import {
|
|
8
8
|
type BncrConversationHistoryMap,
|
|
9
|
+
buildBncrBotReplyMessageId,
|
|
9
10
|
buildBncrConversationHistoryKey,
|
|
10
11
|
buildBncrConversationHistoryKeyFromRoute,
|
|
12
|
+
readConversationHistoryVersion,
|
|
11
13
|
recordBncrBotReply,
|
|
14
|
+
resolveBncrHistoryLimit,
|
|
12
15
|
} from './conversation-history.ts';
|
|
13
16
|
import type { ParsedInbound } from './dispatch-prep.ts';
|
|
14
17
|
|
|
@@ -89,19 +92,21 @@ function recordOutboundReplayEntry(args: {
|
|
|
89
92
|
cache: BncrOutboundReplayCache;
|
|
90
93
|
cacheKey: string;
|
|
91
94
|
entry: BncrOutboundReplayEntry;
|
|
92
|
-
|
|
95
|
+
historyLimit?: number;
|
|
96
|
+
}): boolean {
|
|
93
97
|
const msgId = String(args.entry.messageId || '').trim();
|
|
94
98
|
const existing = args.cache.get(args.cacheKey) || [];
|
|
95
|
-
if (msgId && existing.some((entry) => entry.messageId === msgId)) return;
|
|
99
|
+
if (msgId && existing.some((entry) => entry.messageId === msgId)) return false;
|
|
96
100
|
|
|
97
|
-
const next = [...existing, cloneBncrOutboundReplayEntry(args.entry)]
|
|
98
|
-
(a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0)
|
|
99
|
-
|
|
101
|
+
const next = [...existing, cloneBncrOutboundReplayEntry(args.entry)]
|
|
102
|
+
.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0))
|
|
103
|
+
.slice(-resolveBncrHistoryLimit(args.historyLimit));
|
|
100
104
|
|
|
101
105
|
if (args.cache.has(args.cacheKey)) {
|
|
102
106
|
args.cache.delete(args.cacheKey);
|
|
103
107
|
}
|
|
104
108
|
args.cache.set(args.cacheKey, next);
|
|
109
|
+
return true;
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
function inferMediaKind(args: {
|
|
@@ -156,6 +161,12 @@ function isAppMsg(type: string | undefined, msg: string): boolean {
|
|
|
156
161
|
);
|
|
157
162
|
}
|
|
158
163
|
|
|
164
|
+
export type BncrOutboundReplayRecordResult = {
|
|
165
|
+
recorded: boolean;
|
|
166
|
+
historyOverflow: boolean;
|
|
167
|
+
historyVersion?: number;
|
|
168
|
+
};
|
|
169
|
+
|
|
159
170
|
export function recordBncrOutboundReplay(args: {
|
|
160
171
|
cache: BncrOutboundReplayCache;
|
|
161
172
|
conversationHistories?: BncrConversationHistoryMap;
|
|
@@ -164,14 +175,14 @@ export function recordBncrOutboundReplay(args: {
|
|
|
164
175
|
sender: string;
|
|
165
176
|
senderId?: string;
|
|
166
177
|
status?: BncrOutboundReplayStatus;
|
|
167
|
-
}) {
|
|
178
|
+
}): BncrOutboundReplayRecordResult {
|
|
168
179
|
const cacheKey = buildBncrOutboundReplayKeyFromRoute({
|
|
169
180
|
accountId: args.entry.accountId,
|
|
170
181
|
platform: args.entry.route?.platform,
|
|
171
182
|
groupId: args.entry.route?.groupId,
|
|
172
183
|
userId: args.entry.route?.userId,
|
|
173
184
|
});
|
|
174
|
-
if (!cacheKey) return;
|
|
185
|
+
if (!cacheKey) return { recorded: false, historyOverflow: false };
|
|
175
186
|
|
|
176
187
|
const message =
|
|
177
188
|
args.entry.payload?.message && typeof args.entry.payload.message === 'object'
|
|
@@ -186,15 +197,36 @@ export function recordBncrOutboundReplay(args: {
|
|
|
186
197
|
const body = isAppMsg(type, rawBody)
|
|
187
198
|
? buildMediaBody('document')
|
|
188
199
|
: normalizeTextBody(rawBody) || (isMedia ? buildMediaBody(kind) : '');
|
|
189
|
-
if (!body) return;
|
|
200
|
+
if (!body) return { recorded: false, historyOverflow: false };
|
|
190
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;
|
|
191
228
|
|
|
192
229
|
if (args.conversationHistories) {
|
|
193
|
-
const historyKey = buildBncrConversationHistoryKeyFromRoute({
|
|
194
|
-
platform: args.entry.route?.platform,
|
|
195
|
-
groupId: args.entry.route?.groupId,
|
|
196
|
-
userId: args.entry.route?.userId,
|
|
197
|
-
});
|
|
198
230
|
if (historyKey) {
|
|
199
231
|
recordBncrBotReply({
|
|
200
232
|
historyMap: args.conversationHistories,
|
|
@@ -203,7 +235,7 @@ export function recordBncrOutboundReplay(args: {
|
|
|
203
235
|
senderId: args.senderId,
|
|
204
236
|
body,
|
|
205
237
|
timestamp: recordTimestamp,
|
|
206
|
-
messageId
|
|
238
|
+
messageId,
|
|
207
239
|
historyLimit: args.historyLimit,
|
|
208
240
|
...(mediaUrl
|
|
209
241
|
? {
|
|
@@ -211,24 +243,68 @@ export function recordBncrOutboundReplay(args: {
|
|
|
211
243
|
{
|
|
212
244
|
path: mediaUrl,
|
|
213
245
|
kind,
|
|
214
|
-
messageId
|
|
246
|
+
messageId,
|
|
215
247
|
},
|
|
216
248
|
],
|
|
217
249
|
}
|
|
218
250
|
: {}),
|
|
219
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
|
+
};
|
|
220
296
|
}
|
|
221
297
|
}
|
|
222
|
-
|
|
223
|
-
recordOutboundReplayEntry({
|
|
298
|
+
const cacheRecorded = recordOutboundReplayEntry({
|
|
224
299
|
cache: args.cache,
|
|
225
300
|
cacheKey,
|
|
301
|
+
historyLimit: args.historyLimit,
|
|
226
302
|
entry: {
|
|
227
303
|
sender: args.sender,
|
|
228
304
|
...(args.senderId ? { senderId: args.senderId } : {}),
|
|
229
305
|
body,
|
|
230
306
|
timestamp: recordTimestamp,
|
|
231
|
-
messageId: args.entry.messageId,
|
|
307
|
+
messageId: messageId || args.entry.messageId,
|
|
232
308
|
accountId: args.entry.accountId,
|
|
233
309
|
sessionKey: args.entry.sessionKey,
|
|
234
310
|
route: { ...args.entry.route },
|
|
@@ -242,13 +318,17 @@ export function recordBncrOutboundReplay(args: {
|
|
|
242
318
|
{
|
|
243
319
|
path: mediaUrl,
|
|
244
320
|
kind,
|
|
245
|
-
messageId: args.entry.messageId,
|
|
321
|
+
messageId: messageId || args.entry.messageId,
|
|
246
322
|
},
|
|
247
323
|
],
|
|
248
324
|
}
|
|
249
325
|
: {}),
|
|
250
326
|
},
|
|
251
327
|
});
|
|
328
|
+
return {
|
|
329
|
+
recorded: cacheRecorded,
|
|
330
|
+
historyOverflow: false,
|
|
331
|
+
};
|
|
252
332
|
}
|
|
253
333
|
|
|
254
334
|
export function readBncrOutboundReplaySnapshot(args: {
|
|
@@ -310,3 +390,32 @@ export function clearBncrOutboundReplay(args: {
|
|
|
310
390
|
if (!cacheKey) return;
|
|
311
391
|
args.cache.delete(cacheKey);
|
|
312
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
|
+
}
|
|
@@ -18,7 +18,7 @@ import type {
|
|
|
18
18
|
ParsedInbound,
|
|
19
19
|
} from './dispatch-prep.ts';
|
|
20
20
|
import { buildBncrInboundRecordUpdateLastRoute } from './last-route.ts';
|
|
21
|
-
import { parseBncrNativeCommand } from './native-command.ts';
|
|
21
|
+
import { isBncrStopCommandText, parseBncrNativeCommand } from './native-command.ts';
|
|
22
22
|
import { buildBncrReplyConfig } from './reply-config.ts';
|
|
23
23
|
import { runBncrReplyDispatchSerial } from './reply-dispatch-serial.ts';
|
|
24
24
|
import { resolveBncrChannelInboundRuntime } from './runtime-compat.ts';
|
|
@@ -126,6 +126,7 @@ export async function runBncrInboundReplyDispatch(args: {
|
|
|
126
126
|
senderDisplayName: string;
|
|
127
127
|
shouldDispatch: boolean;
|
|
128
128
|
silentHistoryFlush?: boolean;
|
|
129
|
+
deliveryId?: string;
|
|
129
130
|
setInboundActivity: (accountId: string, at: number) => void;
|
|
130
131
|
scheduleSave: () => void;
|
|
131
132
|
enqueueFromReply: BncrEnqueueFromReply;
|
|
@@ -145,6 +146,7 @@ export async function runBncrInboundReplyDispatch(args: {
|
|
|
145
146
|
senderIdForContext,
|
|
146
147
|
shouldDispatch,
|
|
147
148
|
silentHistoryFlush = false,
|
|
149
|
+
deliveryId,
|
|
148
150
|
setInboundActivity,
|
|
149
151
|
scheduleSave,
|
|
150
152
|
enqueueFromReply,
|
|
@@ -210,10 +212,9 @@ export async function runBncrInboundReplyDispatch(args: {
|
|
|
210
212
|
return;
|
|
211
213
|
}
|
|
212
214
|
|
|
213
|
-
// Stop commands
|
|
214
|
-
|
|
215
|
-
const isStopCommand =
|
|
216
|
-
rawTrimmed === '/stop' || rawTrimmed.startsWith('/stop ') || rawTrimmed.startsWith('/stop@');
|
|
215
|
+
// Stop commands also bypass the reply serial chain; the inbound history serial
|
|
216
|
+
// bypass is handled before this function runs so they can interrupt an agent.
|
|
217
|
+
const isStopCommand = isBncrStopCommandText(rawBody);
|
|
217
218
|
const dispatchSessionKey = resolution.dispatchSessionKey;
|
|
218
219
|
|
|
219
220
|
const runStopOrSerial = () => {
|
|
@@ -225,7 +226,7 @@ export async function runBncrInboundReplyDispatch(args: {
|
|
|
225
226
|
raw: parsed,
|
|
226
227
|
adapter: {
|
|
227
228
|
ingest: () => ({
|
|
228
|
-
id: msgId
|
|
229
|
+
id: deliveryId || msgId || `${resolution.canonicalTo}:${Date.now()}`,
|
|
229
230
|
timestamp: Date.now(),
|
|
230
231
|
rawText: rawBody,
|
|
231
232
|
textForAgent: ctxPayload.BodyForAgent,
|