@xmoxmo/bncr 0.4.5 → 0.4.7

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.
Files changed (36) hide show
  1. package/dist/index.js +6 -0
  2. package/index.ts +6 -0
  3. package/package.json +1 -1
  4. package/src/channel.ts +41 -2
  5. package/src/core/targets.ts +106 -17
  6. package/src/messaging/inbound/commands.ts +263 -51
  7. package/src/messaging/inbound/context-facts.ts +126 -14
  8. package/src/messaging/inbound/contracts.ts +24 -0
  9. package/src/messaging/inbound/dispatch-prep.ts +214 -39
  10. package/src/messaging/inbound/dispatch.ts +71 -5
  11. package/src/messaging/inbound/gate.ts +56 -86
  12. package/src/messaging/inbound/group-history.ts +189 -0
  13. package/src/messaging/inbound/native-command-runtime.ts +77 -61
  14. package/src/messaging/inbound/native-command.ts +92 -8
  15. package/src/messaging/inbound/parse.ts +113 -8
  16. package/src/messaging/inbound/reply-dispatch-serial.ts +62 -0
  17. package/src/messaging/inbound/reply-dispatch.ts +252 -77
  18. package/src/messaging/inbound/scene-admin.ts +269 -0
  19. package/src/messaging/inbound/session-label.ts +122 -13
  20. package/src/messaging/inbound/session-meta-task.ts +17 -0
  21. package/src/messaging/inbound/turn-context.ts +184 -71
  22. package/src/openclaw/channel-runtime-contracts.ts +1 -0
  23. package/src/plugin/channel-components.ts +34 -1
  24. package/src/plugin/channel-inbound-helpers.ts +9 -2
  25. package/src/plugin/channel-runtime-builders-delivery.ts +24 -1
  26. package/src/plugin/channel-runtime-types.ts +42 -0
  27. package/src/plugin/file-inbound-init.ts +27 -12
  28. package/src/plugin/file-inbound-runtime.ts +2 -0
  29. package/src/plugin/inbound-acceptance.ts +82 -1
  30. package/src/plugin/inbound-handlers.ts +55 -2
  31. package/src/plugin/inbound-surface-handlers-group.ts +16 -0
  32. package/src/plugin/messaging.ts +22 -5
  33. package/src/plugin/scene-registry.ts +155 -0
  34. package/src/plugin/state-store.ts +133 -0
  35. package/src/plugin/state-transient-runtime-group.ts +5 -0
  36. package/src/plugin/target-runtime.ts +2 -2
@@ -0,0 +1,62 @@
1
+ import { emitBncrLogLine } from '../../core/logging.ts';
2
+
3
+ const bncrReplyDispatchChains = new Map<string, Promise<void>>();
4
+
5
+ export async function runBncrReplyDispatchSerial<T>(
6
+ sessionKey: string,
7
+ task: () => Promise<T>,
8
+ meta?: { msgId?: string | null; to?: string | null; debugEnabled?: boolean },
9
+ ) {
10
+ const key = String(sessionKey || '').trim();
11
+ if (!key) return await task();
12
+
13
+ const metaSuffix = `|msgId=${String(meta?.msgId || '-')}|to=${String(meta?.to || '-')}`;
14
+
15
+ const previous = bncrReplyDispatchChains.get(key) || Promise.resolve();
16
+ let release!: () => void;
17
+ const current = new Promise<void>((resolve) => {
18
+ release = resolve;
19
+ });
20
+ const chain = previous.then(() => current);
21
+ bncrReplyDispatchChains.set(key, chain);
22
+
23
+ const debugGate = () => meta?.debugEnabled === true;
24
+
25
+ emitBncrLogLine(
26
+ 'info',
27
+ `[bncr] reply-serial queued|sessionKey=${key}${metaSuffix}`,
28
+ { debugOnly: true },
29
+ debugGate,
30
+ );
31
+ await previous;
32
+ emitBncrLogLine(
33
+ 'info',
34
+ `[bncr] reply-serial acquired|sessionKey=${key}${metaSuffix}`,
35
+ { debugOnly: true },
36
+ debugGate,
37
+ );
38
+ try {
39
+ return await task();
40
+ } finally {
41
+ emitBncrLogLine(
42
+ 'info',
43
+ `[bncr] reply-serial releasing|sessionKey=${key}${metaSuffix}`,
44
+ { debugOnly: true },
45
+ debugGate,
46
+ );
47
+ release();
48
+ if (bncrReplyDispatchChains.get(key) === chain) {
49
+ bncrReplyDispatchChains.delete(key);
50
+ }
51
+ emitBncrLogLine(
52
+ 'info',
53
+ `[bncr] reply-serial released|sessionKey=${key}${metaSuffix}`,
54
+ { debugOnly: true },
55
+ debugGate,
56
+ );
57
+ }
58
+ }
59
+
60
+ export function resetBncrReplyDispatchSerialForTest() {
61
+ bncrReplyDispatchChains.clear();
62
+ }
@@ -1,6 +1,7 @@
1
1
  import { emitBncrLogLine } from '../../core/logging.ts';
2
2
  import { resolveBncrChannelPolicy } from '../../core/policy.ts';
3
3
  import {
4
+ readBncrSessionUpdatedAt,
4
5
  recordBncrInboundSession,
5
6
  resolveBncrPinnedMainDmOwnerFromAllowlist,
6
7
  } from '../../openclaw/inbound-session-runtime.ts';
@@ -17,9 +18,97 @@ import type {
17
18
  ParsedInbound,
18
19
  } from './dispatch-prep.ts';
19
20
  import { buildBncrInboundRecordUpdateLastRoute } from './last-route.ts';
21
+ import { parseBncrNativeCommand } from './native-command.ts';
20
22
  import { buildBncrReplyConfig } from './reply-config.ts';
23
+ import { runBncrReplyDispatchSerial } from './reply-dispatch-serial.ts';
21
24
  import { resolveBncrChannelInboundRuntime } from './runtime-compat.ts';
22
- import { wrapBncrInboundRecordSessionLabelCorrection } from './session-label.ts';
25
+ import {
26
+ buildBncrInboundSessionIdentityPatch,
27
+ correctBncrInboundSessionLabel,
28
+ wrapBncrInboundRecordSessionLabelCorrection,
29
+ } from './session-label.ts';
30
+ import { createBncrSessionMetaTaskBarrier } from './session-meta-task.ts';
31
+
32
+ function mergeBncrCommandOwnerAllowFrom(args: {
33
+ cfg: BncrInboundConfig;
34
+ parsed: ParsedInbound;
35
+ isBncrNativeCommand: boolean;
36
+ senderIdForContext: string;
37
+ }) {
38
+ const { cfg, parsed, isBncrNativeCommand, senderIdForContext } = args;
39
+ if (parsed.isAdmin !== true || isBncrNativeCommand) return cfg;
40
+ const senderId = String(senderIdForContext || '').trim();
41
+ if (!senderId) return cfg;
42
+
43
+ const currentCommands = (cfg.commands || {}) as { ownerAllowFrom?: string[] };
44
+ const currentOwnerAllowFrom = Array.isArray(currentCommands.ownerAllowFrom)
45
+ ? currentCommands.ownerAllowFrom
46
+ : [];
47
+ if (currentOwnerAllowFrom.includes(senderId)) return cfg;
48
+
49
+ return {
50
+ ...cfg,
51
+ commands: {
52
+ ...currentCommands,
53
+ ownerAllowFrom: [...currentOwnerAllowFrom, senderId],
54
+ },
55
+ } satisfies BncrInboundConfig;
56
+ }
57
+
58
+ function sleep(ms: number) {
59
+ return new Promise((resolve) => setTimeout(resolve, ms));
60
+ }
61
+
62
+ async function waitForBncrReplySessionQuiescence(args: {
63
+ api: BncrInboundApi;
64
+ storePath: string;
65
+ sessionKey: string;
66
+ msgId?: string | null;
67
+ to: string;
68
+ debugEnabled?: boolean;
69
+ }) {
70
+ const settleWindowMs = 120;
71
+ const pollIntervalMs = 40;
72
+ const maxWaitMs = 1500;
73
+ const startedAt = Date.now();
74
+ const debugGate = () => args.debugEnabled === true;
75
+ let lastUpdatedAt = null as number | null;
76
+ let stableSince = Date.now();
77
+
78
+ const readUpdatedAt = async () => {
79
+ try {
80
+ const updatedAt = await readBncrSessionUpdatedAt(args.api, {
81
+ storePath: args.storePath,
82
+ sessionKey: args.sessionKey,
83
+ });
84
+ return typeof updatedAt === 'number' && Number.isFinite(updatedAt) ? updatedAt : null;
85
+ } catch {
86
+ return null;
87
+ }
88
+ };
89
+
90
+ lastUpdatedAt = await readUpdatedAt();
91
+
92
+ while (Date.now() - startedAt < maxWaitMs) {
93
+ await sleep(pollIntervalMs);
94
+ const currentUpdatedAt = await readUpdatedAt();
95
+ if (currentUpdatedAt !== lastUpdatedAt) {
96
+ lastUpdatedAt = currentUpdatedAt;
97
+ stableSince = Date.now();
98
+ continue;
99
+ }
100
+ if (Date.now() - stableSince >= settleWindowMs) {
101
+ return;
102
+ }
103
+ }
104
+
105
+ emitBncrLogLine(
106
+ 'warn',
107
+ `[bncr] reply-dispatch settle timeout|sessionKey=${args.sessionKey}|msgId=${args.msgId || '-'}|to=${args.to}|waitMs=${Date.now() - startedAt}`,
108
+ { debugOnly: true },
109
+ debugGate,
110
+ );
111
+ }
23
112
 
24
113
  export async function runBncrInboundReplyDispatch(args: {
25
114
  api: BncrInboundApi;
@@ -34,6 +123,8 @@ export async function runBncrInboundReplyDispatch(args: {
34
123
  resolution: BncrInboundConversationResolution;
35
124
  replyRouteFact: BncrInboundReplyRouteFact;
36
125
  senderIdForContext: string;
126
+ senderDisplayName: string;
127
+ shouldDispatch: boolean;
37
128
  setInboundActivity: (accountId: string, at: number) => void;
38
129
  scheduleSave: () => void;
39
130
  enqueueFromReply: BncrEnqueueFromReply;
@@ -51,12 +142,20 @@ export async function runBncrInboundReplyDispatch(args: {
51
142
  resolution,
52
143
  replyRouteFact,
53
144
  senderIdForContext,
145
+ shouldDispatch,
54
146
  setInboundActivity,
55
147
  scheduleSave,
56
148
  enqueueFromReply,
57
149
  } = args;
58
150
 
59
151
  const effectiveReply = buildBncrReplyConfig(cfg);
152
+ const sessionIdentityPatch = buildBncrInboundSessionIdentityPatch({
153
+ channelId,
154
+ accountId: resolution.accountId,
155
+ chatType: resolution.chatType,
156
+ displayTo: resolution.canonicalTo,
157
+ senderId: senderIdForContext,
158
+ });
60
159
  const channelPolicy = resolveBncrChannelPolicy(cfg?.channels?.bncr || {});
61
160
  const pinnedMainDmOwner =
62
161
  peer.kind === 'direct'
@@ -76,87 +175,163 @@ export async function runBncrInboundReplyDispatch(args: {
76
175
  sessionKey: resolution.dispatchSessionKey,
77
176
  pinnedMainDmOwner,
78
177
  });
178
+ const isBncrNativeCommand =
179
+ parseBncrNativeCommand(rawBody, {
180
+ allowBareWhoami: parsed.isAdmin !== true,
181
+ }) !== null;
182
+ const commandDispatchCfg = mergeBncrCommandOwnerAllowFrom({
183
+ cfg,
184
+ parsed,
185
+ isBncrNativeCommand,
186
+ senderIdForContext,
187
+ });
79
188
 
80
- await resolveBncrChannelInboundRuntime(api).run({
81
- channel: channelId,
82
- accountId: resolution.accountId,
83
- raw: parsed,
84
- adapter: {
85
- ingest: () => ({
86
- id: msgId ?? `${resolution.canonicalTo}:${Date.now()}`,
87
- timestamp: Date.now(),
88
- rawText: rawBody,
89
- textForAgent: ctxPayload.BodyForAgent,
90
- textForCommands: ctxPayload.CommandBody,
91
- raw: parsed,
92
- }),
93
- resolveTurn: () => ({
189
+ if (!shouldDispatch) {
190
+ await wrapBncrInboundRecordSessionLabelCorrection({
191
+ recordInboundSession: recordBncrInboundSession as (
192
+ ...args: unknown[]
193
+ ) => Promise<unknown> | unknown,
194
+ expectedPatch: sessionIdentityPatch,
195
+ })({
196
+ storePath,
197
+ sessionKey: resolution.resolvedRoute.sessionKey,
198
+ ctx: ctxPayload,
199
+ updateLastRoute,
200
+ onRecordError: (err: unknown) => {
201
+ emitBncrLogLine('warn', `[bncr] inbound record session failed: ${String(err)}`);
202
+ },
203
+ });
204
+
205
+ const inboundAt = Date.now();
206
+ setInboundActivity(resolution.accountId, inboundAt);
207
+ scheduleSave();
208
+ return;
209
+ }
210
+
211
+ await runBncrReplyDispatchSerial(
212
+ resolution.dispatchSessionKey,
213
+ async () =>
214
+ resolveBncrChannelInboundRuntime(api).run({
94
215
  channel: channelId,
95
216
  accountId: resolution.accountId,
96
- routeSessionKey: resolution.resolvedRoute.sessionKey,
97
- storePath,
98
- ctxPayload,
99
- recordInboundSession: wrapBncrInboundRecordSessionLabelCorrection({
100
- recordInboundSession: recordBncrInboundSession as (
101
- ...args: unknown[]
102
- ) => Promise<unknown> | unknown,
103
- expectedLabel: resolution.canonicalTo,
104
- }),
105
- record: {
106
- updateLastRoute,
107
- onRecordError: (err: unknown) => {
108
- emitBncrLogLine('warn', `[bncr] inbound record session failed: ${String(err)}`);
109
- },
110
- },
111
- runDispatch: () =>
112
- dispatchOpenClawReplyWithBufferedBlockDispatcher(api, {
113
- ctx: ctxPayload,
114
- cfg: effectiveReply.replyCfg,
115
- dispatcherOptions: {
116
- deliver: async (
117
- payload: {
118
- text?: string;
119
- mediaUrl?: string;
120
- mediaUrls?: string[];
121
- audioAsVoice?: boolean;
122
- },
123
- info?: { kind?: 'tool' | 'block' | 'final' },
124
- ) => {
125
- const kind = info?.kind;
126
- const shouldForwardTool = effectiveReply.blockStreaming && effectiveReply.allowTool;
127
-
128
- if (kind === 'tool' && !shouldForwardTool) {
129
- return;
130
- }
131
-
132
- await enqueueFromReply({
133
- accountId: replyRouteFact.accountId,
134
- sessionKey: replyRouteFact.sessionKey,
135
- route: replyRouteFact.route,
136
- payload: {
137
- text: payload.text,
138
- mediaUrl: payload.mediaUrl,
139
- mediaUrls: payload.mediaUrls,
140
- kind: kind || 'final',
141
- replyToId: msgId || undefined,
217
+ raw: parsed,
218
+ adapter: {
219
+ ingest: () => ({
220
+ id: msgId ?? `${resolution.canonicalTo}:${Date.now()}`,
221
+ timestamp: Date.now(),
222
+ rawText: rawBody,
223
+ textForAgent: ctxPayload.BodyForAgent,
224
+ textForCommands: ctxPayload.CommandBody,
225
+ raw: parsed,
226
+ }),
227
+ preflight: () =>
228
+ shouldDispatch
229
+ ? undefined
230
+ : {
231
+ admission: {
232
+ kind: 'observeOnly' as const,
233
+ reason: 'bncr-group-mode-no-reply',
142
234
  },
143
- });
235
+ },
236
+ resolveTurn: () => {
237
+ const sessionMetaBarrier = createBncrSessionMetaTaskBarrier();
238
+ return {
239
+ channel: channelId,
240
+ accountId: resolution.accountId,
241
+ routeSessionKey: resolution.resolvedRoute.sessionKey,
242
+ storePath,
243
+ ctxPayload,
244
+ recordInboundSession: wrapBncrInboundRecordSessionLabelCorrection({
245
+ recordInboundSession: recordBncrInboundSession as (
246
+ ...args: unknown[]
247
+ ) => Promise<unknown> | unknown,
248
+ expectedPatch: sessionIdentityPatch,
249
+ }),
250
+ record: {
251
+ updateLastRoute,
252
+ onRecordError: (err: unknown) => {
253
+ emitBncrLogLine('warn', `[bncr] inbound record session failed: ${String(err)}`);
254
+ },
255
+ trackSessionMetaTask: (task: Promise<unknown>) => {
256
+ sessionMetaBarrier.track(task);
257
+ },
144
258
  },
145
- onError: (err: unknown) => {
146
- emitBncrLogLine('error', `[bncr] outbound reply failed: ${String(err)}`);
259
+ runDispatch: async () => {
260
+ await sessionMetaBarrier.wait();
261
+ await correctBncrInboundSessionLabel({
262
+ storePath,
263
+ sessionKey: resolution.dispatchSessionKey,
264
+ expectedPatch: sessionIdentityPatch,
265
+ });
266
+ return Promise.resolve(
267
+ dispatchOpenClawReplyWithBufferedBlockDispatcher(api, {
268
+ ctx: ctxPayload,
269
+ cfg: buildBncrReplyConfig(commandDispatchCfg).replyCfg,
270
+ dispatcherOptions: {
271
+ deliver: async (
272
+ payload: {
273
+ text?: string;
274
+ mediaUrl?: string;
275
+ mediaUrls?: string[];
276
+ audioAsVoice?: boolean;
277
+ },
278
+ info?: { kind?: 'tool' | 'block' | 'final' },
279
+ ) => {
280
+ const kind = info?.kind;
281
+ const shouldForwardTool =
282
+ effectiveReply.blockStreaming && effectiveReply.allowTool;
283
+
284
+ if (kind === 'tool' && !shouldForwardTool) {
285
+ return;
286
+ }
287
+
288
+ await enqueueFromReply({
289
+ accountId: replyRouteFact.accountId,
290
+ sessionKey: replyRouteFact.sessionKey,
291
+ route: replyRouteFact.route,
292
+ payload: {
293
+ text: payload.text,
294
+ mediaUrl: payload.mediaUrl,
295
+ mediaUrls: payload.mediaUrls,
296
+ kind: kind || 'final',
297
+ replyToId: msgId || undefined,
298
+ },
299
+ });
300
+ },
301
+ onError: (err: unknown) => {
302
+ emitBncrLogLine('error', `[bncr] outbound reply failed: ${String(err)}`);
303
+ },
304
+ },
305
+ replyOptions: {
306
+ disableBlockStreaming: !effectiveReply.blockStreaming,
307
+ shouldEmitToolResult: effectiveReply.allowTool ? () => true : undefined,
308
+ },
309
+ }),
310
+ ).finally(async () => {
311
+ await waitForBncrReplySessionQuiescence({
312
+ api,
313
+ storePath,
314
+ sessionKey: resolution.dispatchSessionKey,
315
+ msgId,
316
+ to: resolution.canonicalTo,
317
+ debugEnabled: cfg?.channels?.bncr?.debug?.verbose === true,
318
+ });
319
+ await correctBncrInboundSessionLabel({
320
+ storePath,
321
+ sessionKey: resolution.dispatchSessionKey,
322
+ expectedPatch: sessionIdentityPatch,
323
+ });
324
+ });
147
325
  },
148
- },
149
- replyOptions: {
150
- disableBlockStreaming: !effectiveReply.blockStreaming,
151
- shouldEmitToolResult: effectiveReply.allowTool ? () => true : undefined,
152
- },
153
- }),
326
+ };
327
+ },
328
+ onFinalize: () => {
329
+ const inboundAt = Date.now();
330
+ setInboundActivity(resolution.accountId, inboundAt);
331
+ scheduleSave();
332
+ },
333
+ },
154
334
  }),
155
- onFinalize: () => {
156
- const inboundAt = Date.now();
157
- setInboundActivity(resolution.accountId, inboundAt);
158
- scheduleSave();
159
- },
160
- },
161
- });
335
+ { msgId, to: resolution.canonicalTo },
336
+ );
162
337
  }