@xmoxmo/bncr 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +68 -5
  2. package/package.json +1 -1
  3. package/src/channel.ts +2985 -1706
  4. package/src/core/connection-capability.ts +70 -0
  5. package/src/core/connection-reachability.ts +168 -0
  6. package/src/core/diagnostics.ts +54 -0
  7. package/src/core/downlink-health.ts +61 -0
  8. package/src/core/extended-diagnostics.ts +65 -0
  9. package/src/core/lease-state.ts +94 -0
  10. package/src/core/outbox-enqueue.ts +22 -0
  11. package/src/core/outbox-entry-builders.ts +92 -0
  12. package/src/core/outbox-file-transfer-bookkeeping.ts +31 -0
  13. package/src/core/outbox-file-transfer-failure.ts +25 -0
  14. package/src/core/outbox-file-transfer-guards.ts +66 -0
  15. package/src/core/outbox-file-transfer-prep.ts +31 -0
  16. package/src/core/outbox-file-transfer-success.ts +34 -0
  17. package/src/core/outbox-push-args.ts +67 -0
  18. package/src/core/outbox-queue.ts +69 -0
  19. package/src/core/outbox-summary.ts +14 -0
  20. package/src/core/outbox-text-push-failure.ts +10 -0
  21. package/src/core/outbox-text-push-guards.ts +51 -0
  22. package/src/core/outbox-text-push-prep.ts +36 -0
  23. package/src/core/outbox-text-push-success.ts +62 -0
  24. package/src/core/policy.ts +9 -0
  25. package/src/core/register-trace.ts +115 -0
  26. package/src/core/status.ts +57 -0
  27. package/src/core/types.ts +1 -0
  28. package/src/messaging/inbound/commands.ts +318 -75
  29. package/src/messaging/inbound/dispatch.ts +435 -139
  30. package/src/messaging/inbound/parse.ts +8 -0
  31. package/src/messaging/inbound/session-label.ts +115 -0
  32. package/src/messaging/outbound/diagnostics.ts +262 -0
  33. package/src/messaging/outbound/media-dedupe.ts +51 -0
  34. package/src/messaging/outbound/media.ts +3 -1
  35. package/src/messaging/outbound/queue-selectors.ts +191 -0
  36. package/src/messaging/outbound/reasons.ts +52 -0
  37. package/src/messaging/outbound/reply-enqueue.ts +329 -0
  38. package/src/messaging/outbound/reply-target-policy.ts +13 -0
  39. package/src/messaging/outbound/retry-policy.ts +142 -0
  40. package/src/messaging/outbound/send.ts +6 -0
  41. package/src/messaging/outbound/session-route.ts +34 -5
@@ -1,4 +1,8 @@
1
+ import fs from 'node:fs';
2
+ import { resolvePinnedMainDmOwnerFromAllowlist } from 'openclaw/plugin-sdk/conversation-runtime';
3
+ import { resolveInboundLastRouteSessionKey } from 'openclaw/plugin-sdk/routing';
1
4
  import { emitBncrLogLine } from '../../core/logging.ts';
5
+ import { resolveBncrChannelPolicy } from '../../core/policy.ts';
2
6
  import {
3
7
  formatDisplayScope,
4
8
  normalizeInboundSessionKey,
@@ -6,93 +10,139 @@ import {
6
10
  } from '../../core/targets.ts';
7
11
  import { handleBncrNativeCommand } from './commands.ts';
8
12
  import { buildBncrReplyConfig } from './reply-config.ts';
13
+ import { wrapBncrInboundRecordSessionLabelCorrection } from './session-label.ts';
9
14
 
10
15
  type ParsedInbound = ReturnType<typeof import('./parse.ts')['parseBncrInboundParams']>;
11
16
 
12
- export async function dispatchBncrInbound(params: {
17
+ type BncrInboundConversationResolution = {
18
+ accountId: string;
19
+ chatType: 'direct' | 'group';
20
+ route: ParsedInbound['route'];
21
+ resolvedRoute: {
22
+ sessionKey: string;
23
+ agentId: string;
24
+ mainSessionKey?: string;
25
+ };
26
+ canonicalTo: string;
27
+ rawTo: string;
28
+ originatingTo: string;
29
+ baseSessionKey: string;
30
+ taskSessionKey?: string;
31
+ dispatchSessionKey: string;
32
+ };
33
+
34
+ type BncrInboundReplyRouteFact = {
35
+ accountId: string;
36
+ sessionKey: string;
37
+ route: ParsedInbound['route'];
38
+ canonicalTo: string;
39
+ originatingTo: string;
40
+ chatType: 'direct' | 'group';
41
+ };
42
+
43
+ const INBOUND_MEDIA_MAX_BYTES = 30 * 1024 * 1024;
44
+
45
+ export function estimateBase64DecodedBytes(value: string): number {
46
+ const normalized = String(value || '').replace(/\s+/g, '');
47
+ if (!normalized) return 0;
48
+ const padding = normalized.endsWith('==') ? 2 : normalized.endsWith('=') ? 1 : 0;
49
+ return Math.max(0, Math.floor((normalized.length * 3) / 4) - padding);
50
+ }
51
+
52
+ export function assertInboundMediaBase64Size(
53
+ value: string,
54
+ maxBytes = INBOUND_MEDIA_MAX_BYTES,
55
+ ) {
56
+ const estimatedBytes = estimateBase64DecodedBytes(value);
57
+ if (estimatedBytes > maxBytes) {
58
+ throw new Error(
59
+ `inbound media too large: estimated ${estimatedBytes} bytes exceeds ${maxBytes} bytes`,
60
+ );
61
+ }
62
+ }
63
+
64
+ export function decodeInboundMediaBase64(
65
+ value: string,
66
+ maxBytes = INBOUND_MEDIA_MAX_BYTES,
67
+ ): Buffer {
68
+ assertInboundMediaBase64Size(value, maxBytes);
69
+ const normalized = String(value || '').replace(/\s+/g, '');
70
+ const mediaBuf = Buffer.from(normalized, 'base64');
71
+ if (!mediaBuf.length) {
72
+ throw new Error('inbound media base64 decoded to empty buffer');
73
+ }
74
+ if (mediaBuf.length > maxBytes) {
75
+ throw new Error(
76
+ `inbound media too large: decoded ${mediaBuf.length} bytes exceeds ${maxBytes} bytes`,
77
+ );
78
+ }
79
+ return mediaBuf;
80
+ }
81
+
82
+ function formatRawBncrInboundTarget(route: ParsedInbound['route']): string {
83
+ return `Bncr:${String(route.platform || '').trim()}:${String(route.groupId || '').trim()}:${String(route.userId || '').trim()}`;
84
+ }
85
+
86
+ export function resolveBncrInboundConversation(args: {
13
87
  api: any;
14
- channelId: string;
15
88
  cfg: any;
89
+ channelId: string;
16
90
  parsed: ParsedInbound;
17
91
  canonicalAgentId: string;
18
- rememberSessionRoute: (sessionKey: string, accountId: string, route: any) => void;
19
- enqueueFromReply: (args: {
20
- accountId: string;
21
- sessionKey: string;
22
- route: any;
23
- payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] };
24
- mediaLocalRoots?: readonly string[];
25
- }) => Promise<void>;
26
- setInboundActivity: (accountId: string, at: number) => void;
27
- scheduleSave: () => void;
28
- logger?: { warn?: (msg: string) => void; error?: (msg: string) => void };
29
92
  }) {
30
- const {
31
- api,
32
- channelId,
93
+ const { api, cfg, channelId, parsed, canonicalAgentId } = args;
94
+ const { accountId, route, peer, sessionKeyfromroute, providedOriginatingTo, extracted } = parsed;
95
+
96
+ const resolvedRoute = api.runtime.channel.routing.resolveAgentRoute({
33
97
  cfg,
34
- parsed,
35
- canonicalAgentId,
36
- rememberSessionRoute,
37
- enqueueFromReply,
38
- setInboundActivity,
39
- scheduleSave,
40
- logger,
41
- } = params;
42
- const {
98
+ channel: channelId,
43
99
  accountId,
44
- route,
45
100
  peer,
46
- sessionKeyfromroute,
47
- clientId,
48
- text,
101
+ });
102
+
103
+ const baseSessionKey =
104
+ normalizeInboundSessionKey(sessionKeyfromroute, route, canonicalAgentId) ||
105
+ resolvedRoute.sessionKey;
106
+ const taskSessionKey = withTaskSessionKey(baseSessionKey, extracted.taskKey);
107
+ const dispatchSessionKey = taskSessionKey || baseSessionKey;
108
+ const rawTo = formatRawBncrInboundTarget(route);
109
+ const canonicalTo = formatDisplayScope(route);
110
+ const originatingTo = providedOriginatingTo || rawTo;
111
+
112
+ return {
113
+ accountId,
114
+ chatType: peer.kind,
115
+ route,
116
+ resolvedRoute,
117
+ canonicalTo,
118
+ rawTo,
119
+ originatingTo,
120
+ baseSessionKey,
121
+ ...(taskSessionKey ? { taskSessionKey } : {}),
122
+ dispatchSessionKey,
123
+ } satisfies BncrInboundConversationResolution;
124
+ }
125
+
126
+ async function prepareBncrInboundSessionContext(args: {
127
+ api: any;
128
+ cfg: any;
129
+ parsed: ParsedInbound;
130
+ resolution: BncrInboundConversationResolution;
131
+ rememberSessionRoute: (sessionKey: string, accountId: string, route: any) => void;
132
+ }) {
133
+ const { api, cfg, parsed, resolution, rememberSessionRoute } = args;
134
+ const {
49
135
  msgType,
50
136
  mediaBase64,
51
137
  mediaPathFromTransfer,
52
138
  mimeType,
53
139
  fileName,
54
- msgId,
55
140
  extracted,
56
141
  platform,
57
142
  groupId,
58
143
  userId,
59
144
  } = parsed;
60
-
61
- const nativeCommand = await handleBncrNativeCommand({
62
- api,
63
- channelId,
64
- cfg,
65
- parsed,
66
- canonicalAgentId,
67
- rememberSessionRoute,
68
- enqueueFromReply,
69
- logger,
70
- });
71
- if (nativeCommand.handled) {
72
- const inboundAt = Date.now();
73
- setInboundActivity(accountId, inboundAt);
74
- scheduleSave();
75
- return {
76
- accountId,
77
- sessionKey: nativeCommand.sessionKey,
78
- taskKey: extracted.taskKey ?? null,
79
- msgId: msgId ?? null,
80
- };
81
- }
82
-
83
- const resolvedRoute = api.runtime.channel.routing.resolveAgentRoute({
84
- cfg,
85
- channel: channelId,
86
- accountId,
87
- peer,
88
- });
89
-
90
- const baseSessionKey =
91
- normalizeInboundSessionKey(sessionKeyfromroute, route, canonicalAgentId) ||
92
- resolvedRoute.sessionKey;
93
- const agentText = extracted.text;
94
- const taskSessionKey = withTaskSessionKey(baseSessionKey, extracted.taskKey);
95
- const sessionKey = taskSessionKey || baseSessionKey;
145
+ const { accountId, route, resolvedRoute, baseSessionKey, taskSessionKey, dispatchSessionKey } = resolution;
96
146
 
97
147
  rememberSessionRoute(baseSessionKey, accountId, route);
98
148
  if (taskSessionKey && taskSessionKey !== baseSessionKey) {
@@ -105,7 +155,7 @@ export async function dispatchBncrInbound(params: {
105
155
 
106
156
  let mediaPath: string | undefined;
107
157
  if (mediaBase64) {
108
- const mediaBuf = Buffer.from(mediaBase64, 'base64');
158
+ const mediaBuf = decodeInboundMediaBase64(mediaBase64);
109
159
  const saved = await api.runtime.channel.media.saveMediaBuffer(
110
160
  mediaBuf,
111
161
  mimeType,
@@ -118,103 +168,349 @@ export async function dispatchBncrInbound(params: {
118
168
  mediaPath = mediaPathFromTransfer;
119
169
  }
120
170
 
121
- const rawBody = agentText || (msgType === 'text' ? '' : `[${msgType}]`);
171
+ const rawBody = extracted.text || (msgType === 'text' ? '' : `[${msgType}]`);
122
172
  const body = api.runtime.channel.reply.formatAgentEnvelope({
123
173
  channel: 'Bncr',
124
174
  from: `${platform}:${groupId}:${userId}`,
125
175
  timestamp: Date.now(),
126
176
  previousTimestamp: api.runtime.channel.session.readSessionUpdatedAt({
127
177
  storePath,
128
- sessionKey,
178
+ sessionKey: dispatchSessionKey,
129
179
  }),
130
180
  envelope: api.runtime.channel.reply.resolveEnvelopeFormatOptions(cfg),
131
181
  body: rawBody,
132
182
  });
133
183
 
134
- const displayTo = formatDisplayScope(route);
135
- if (!clientId) {
136
- emitBncrLogLine('warn', '[bncr] inbound missing clientId for chat identity');
184
+ return {
185
+ storePath,
186
+ mediaPath,
187
+ rawBody,
188
+ body,
189
+ };
190
+ }
191
+
192
+ function buildBncrInboundTurnContext(args: {
193
+ api: any;
194
+ channelId: string;
195
+ msgId?: string | null;
196
+ mimeType?: string;
197
+ mediaPath?: string;
198
+ peer: ParsedInbound['peer'];
199
+ senderIdForContext: string;
200
+ senderDisplayName: string;
201
+ resolution: BncrInboundConversationResolution;
202
+ prepared: {
203
+ rawBody: string;
204
+ body: string;
205
+ };
206
+ }) {
207
+ const {
208
+ api,
209
+ channelId,
210
+ msgId,
211
+ mimeType,
212
+ mediaPath,
213
+ peer,
214
+ senderIdForContext,
215
+ senderDisplayName,
216
+ resolution,
217
+ prepared,
218
+ } = args;
219
+
220
+ return api.runtime.channel.turn.buildContext({
221
+ channel: channelId,
222
+ provider: channelId,
223
+ surface: channelId,
224
+ accountId: resolution.accountId,
225
+ messageId: msgId,
226
+ timestamp: Date.now(),
227
+ from: senderIdForContext,
228
+ sender: {
229
+ id: senderIdForContext,
230
+ name: senderDisplayName,
231
+ username: senderDisplayName,
232
+ },
233
+ conversation: {
234
+ kind: resolution.chatType,
235
+ id: peer.id,
236
+ label: resolution.canonicalTo,
237
+ routePeer: {
238
+ kind: peer.kind,
239
+ id: peer.id,
240
+ },
241
+ },
242
+ route: {
243
+ agentId: resolution.resolvedRoute.agentId,
244
+ accountId: resolution.accountId,
245
+ routeSessionKey: resolution.resolvedRoute.sessionKey,
246
+ dispatchSessionKey: resolution.dispatchSessionKey,
247
+ mainSessionKey: resolution.resolvedRoute.mainSessionKey,
248
+ },
249
+ reply: {
250
+ to: resolution.canonicalTo,
251
+ originatingTo: resolution.originatingTo,
252
+ },
253
+ message: {
254
+ inboundEventKind: 'user_request',
255
+ body: prepared.body,
256
+ rawBody: prepared.rawBody,
257
+ bodyForAgent: prepared.rawBody,
258
+ commandBody: prepared.rawBody,
259
+ envelopeFrom: resolution.originatingTo,
260
+ senderLabel: senderDisplayName,
261
+ },
262
+ media: mediaPath
263
+ ? [
264
+ {
265
+ path: mediaPath,
266
+ contentType: mimeType,
267
+ kind: mimeType?.startsWith('image/')
268
+ ? 'image'
269
+ : mimeType?.startsWith('video/')
270
+ ? 'video'
271
+ : mimeType?.startsWith('audio/')
272
+ ? 'audio'
273
+ : 'document',
274
+ messageId: msgId ?? undefined,
275
+ },
276
+ ]
277
+ : [],
278
+ extra: {
279
+ OriginatingChannel: channelId,
280
+ },
281
+ });
282
+ }
283
+
284
+ function buildBncrInboundRecordUpdateLastRoute(args: {
285
+ channelId: string;
286
+ peer: ParsedInbound['peer'];
287
+ senderIdForContext: string;
288
+ resolution: BncrInboundConversationResolution;
289
+ pinnedMainDmOwner: string | null;
290
+ }) {
291
+ const { channelId, peer, senderIdForContext, resolution, pinnedMainDmOwner } = args;
292
+ if (peer.kind !== 'direct') return undefined;
293
+
294
+ const sessionKey = resolveInboundLastRouteSessionKey({
295
+ route: resolution.resolvedRoute,
296
+ sessionKey: resolution.dispatchSessionKey,
297
+ });
298
+
299
+ return {
300
+ sessionKey,
301
+ channel: channelId,
302
+ to: resolution.canonicalTo,
303
+ accountId: resolution.accountId,
304
+ mainDmOwnerPin:
305
+ sessionKey === resolution.resolvedRoute.mainSessionKey && pinnedMainDmOwner
306
+ ? {
307
+ ownerRecipient: pinnedMainDmOwner,
308
+ senderRecipient: senderIdForContext,
309
+ }
310
+ : undefined,
311
+ };
312
+ }
313
+
314
+ function buildBncrInboundReplyRouteFact(
315
+ resolution: BncrInboundConversationResolution,
316
+ ): BncrInboundReplyRouteFact {
317
+ return {
318
+ accountId: resolution.accountId,
319
+ sessionKey: resolution.dispatchSessionKey,
320
+ route: resolution.route,
321
+ canonicalTo: resolution.canonicalTo,
322
+ originatingTo: resolution.originatingTo,
323
+ chatType: resolution.chatType,
324
+ };
325
+ }
326
+
327
+ export async function dispatchBncrInbound(params: {
328
+ api: any;
329
+ channelId: string;
330
+ cfg: any;
331
+ parsed: ParsedInbound;
332
+ canonicalAgentId: string;
333
+ rememberSessionRoute: (sessionKey: string, accountId: string, route: any) => void;
334
+ enqueueFromReply: (args: {
335
+ accountId: string;
336
+ sessionKey: string;
337
+ route: any;
338
+ payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] };
339
+ mediaLocalRoots?: readonly string[];
340
+ }) => Promise<void>;
341
+ setInboundActivity: (accountId: string, at: number) => void;
342
+ scheduleSave: () => void;
343
+ logger?: { warn?: (msg: string) => void; error?: (msg: string) => void };
344
+ }) {
345
+ const {
346
+ api,
347
+ channelId,
348
+ cfg,
349
+ parsed,
350
+ canonicalAgentId,
351
+ rememberSessionRoute,
352
+ enqueueFromReply,
353
+ setInboundActivity,
354
+ scheduleSave,
355
+ logger,
356
+ } = params;
357
+ const { accountId, clientId, msgId, extracted, mimeType, peer } = parsed;
358
+
359
+ const nativeCommand = await handleBncrNativeCommand({
360
+ api,
361
+ channelId,
362
+ cfg,
363
+ parsed,
364
+ canonicalAgentId,
365
+ rememberSessionRoute,
366
+ enqueueFromReply,
367
+ logger,
368
+ });
369
+ if (nativeCommand.handled && !nativeCommand.fallbackToAgent) {
370
+ const inboundAt = Date.now();
371
+ setInboundActivity(accountId, inboundAt);
372
+ scheduleSave();
137
373
  return {
138
374
  accountId,
139
- sessionKey,
375
+ sessionKey: nativeCommand.sessionKey,
140
376
  taskKey: extracted.taskKey ?? null,
141
377
  msgId: msgId ?? null,
142
378
  };
143
379
  }
144
- const senderIdForContext = clientId;
145
- const senderDisplayName = 'bncr-client';
146
- const ctxPayload = api.runtime.channel.reply.finalizeInboundContext({
147
- Body: body,
148
- BodyForAgent: rawBody,
149
- RawBody: rawBody,
150
- CommandBody: rawBody,
151
- MediaPath: mediaPath,
152
- MediaType: mimeType,
153
- From: senderIdForContext,
154
- To: displayTo,
155
- SessionKey: sessionKey,
156
- AccountId: accountId,
157
- ChatType: peer.kind,
158
- ConversationLabel: displayTo,
159
- SenderId: senderIdForContext,
160
- SenderName: senderDisplayName,
161
- SenderUsername: senderDisplayName,
162
- Provider: channelId,
163
- Surface: channelId,
164
- MessageSid: msgId,
165
- Timestamp: Date.now(),
166
- OriginatingChannel: channelId,
167
- OriginatingTo: displayTo,
168
- });
169
380
 
170
- await api.runtime.channel.session.recordInboundSession({
171
- storePath,
172
- sessionKey,
173
- ctx: ctxPayload,
174
- onRecordError: (err: unknown) => {
175
- emitBncrLogLine('warn', `[bncr] inbound record session failed: ${String(err)}`);
176
- },
381
+ const resolution = resolveBncrInboundConversation({
382
+ api,
383
+ cfg,
384
+ channelId,
385
+ parsed,
386
+ canonicalAgentId,
387
+ });
388
+ const { resolvedRoute, canonicalTo, dispatchSessionKey: sessionKey } = resolution;
389
+ const prepared = await prepareBncrInboundSessionContext({
390
+ api,
391
+ cfg,
392
+ parsed,
393
+ resolution,
394
+ rememberSessionRoute,
395
+ });
396
+ const { storePath, mediaPath, rawBody, body } = prepared;
397
+ const replyRouteFact = buildBncrInboundReplyRouteFact(resolution);
398
+ if (!clientId) {
399
+ emitBncrLogLine(
400
+ 'warn',
401
+ '[bncr] inbound missing clientId for chat identity; using route identity fallback',
402
+ );
403
+ }
404
+ const senderIdForContext = clientId || canonicalTo;
405
+ const senderDisplayName = clientId ? 'bncr-client' : canonicalTo;
406
+ const ctxPayload = buildBncrInboundTurnContext({
407
+ api,
408
+ channelId,
409
+ msgId,
410
+ mimeType,
411
+ mediaPath,
412
+ peer,
413
+ senderIdForContext,
414
+ senderDisplayName,
415
+ resolution,
416
+ prepared,
177
417
  });
178
-
179
- const inboundAt = Date.now();
180
- setInboundActivity(accountId, inboundAt);
181
- scheduleSave();
182
418
 
183
419
  const effectiveReply = buildBncrReplyConfig(cfg);
420
+ const channelPolicy = resolveBncrChannelPolicy(cfg?.channels?.bncr || {});
421
+ const pinnedMainDmOwner =
422
+ peer.kind === 'direct'
423
+ ? resolvePinnedMainDmOwnerFromAllowlist({
424
+ dmScope: cfg?.session?.dmScope,
425
+ allowFrom: channelPolicy.allowFrom,
426
+ normalizeEntry: (entry: string) => String(entry || '').trim(),
427
+ })
428
+ : null;
429
+ const updateLastRoute = buildBncrInboundRecordUpdateLastRoute({
430
+ channelId,
431
+ peer,
432
+ senderIdForContext,
433
+ resolution,
434
+ pinnedMainDmOwner,
435
+ });
184
436
 
185
- await api.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
186
- ctx: ctxPayload,
187
- cfg: effectiveReply.replyCfg,
188
- dispatcherOptions: {
189
- deliver: async (
190
- payload: { text?: string; mediaUrl?: string; mediaUrls?: string[]; audioAsVoice?: boolean },
191
- info?: { kind?: 'tool' | 'block' | 'final' },
192
- ) => {
193
- const kind = info?.kind;
194
- const shouldForwardTool = effectiveReply.blockStreaming && effectiveReply.allowTool;
195
-
196
- if (kind === 'tool' && !shouldForwardTool) {
197
- return;
198
- }
199
-
200
- await enqueueFromReply({
201
- accountId,
202
- sessionKey,
203
- route,
204
- payload: {
205
- ...payload,
206
- kind: kind as 'tool' | 'block' | 'final' | undefined,
437
+ await api.runtime.channel.turn.run({
438
+ channel: channelId,
439
+ accountId,
440
+ raw: parsed,
441
+ adapter: {
442
+ ingest: () => ({
443
+ id: msgId ?? `${canonicalTo}:${Date.now()}`,
444
+ timestamp: Date.now(),
445
+ rawText: rawBody,
446
+ textForAgent: ctxPayload.BodyForAgent,
447
+ textForCommands: ctxPayload.CommandBody,
448
+ raw: parsed,
449
+ }),
450
+ resolveTurn: () => ({
451
+ channel: channelId,
452
+ accountId,
453
+ routeSessionKey: resolvedRoute.sessionKey,
454
+ storePath,
455
+ ctxPayload,
456
+ recordInboundSession: wrapBncrInboundRecordSessionLabelCorrection({
457
+ recordInboundSession: api.runtime.channel.session.recordInboundSession,
458
+ expectedLabel: canonicalTo,
459
+ }),
460
+ record: {
461
+ updateLastRoute,
462
+ onRecordError: (err: unknown) => {
463
+ emitBncrLogLine('warn', `[bncr] inbound record session failed: ${String(err)}`);
207
464
  },
208
- });
209
- },
210
- onError: (err: unknown) => {
211
- emitBncrLogLine('error', `[bncr] outbound reply failed: ${String(err)}`);
465
+ },
466
+ runDispatch: () =>
467
+ api.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
468
+ ctx: ctxPayload,
469
+ cfg: effectiveReply.replyCfg,
470
+ dispatcherOptions: {
471
+ deliver: async (
472
+ payload: {
473
+ text?: string;
474
+ mediaUrl?: string;
475
+ mediaUrls?: string[];
476
+ audioAsVoice?: boolean;
477
+ },
478
+ info?: { kind?: 'tool' | 'block' | 'final' },
479
+ ) => {
480
+ const kind = info?.kind;
481
+ const shouldForwardTool = effectiveReply.blockStreaming && effectiveReply.allowTool;
482
+
483
+ if (kind === 'tool' && !shouldForwardTool) {
484
+ return;
485
+ }
486
+
487
+ await enqueueFromReply({
488
+ accountId: replyRouteFact.accountId,
489
+ sessionKey: replyRouteFact.sessionKey,
490
+ route: replyRouteFact.route,
491
+ payload: {
492
+ ...payload,
493
+ kind: kind as 'tool' | 'block' | 'final' | undefined,
494
+ replyToId: msgId || undefined,
495
+ },
496
+ });
497
+ },
498
+ onError: (err: unknown) => {
499
+ emitBncrLogLine('error', `[bncr] outbound reply failed: ${String(err)}`);
500
+ },
501
+ },
502
+ replyOptions: {
503
+ disableBlockStreaming: !effectiveReply.blockStreaming,
504
+ shouldEmitToolResult: effectiveReply.allowTool ? () => true : undefined,
505
+ },
506
+ }),
507
+ }),
508
+ onFinalize: () => {
509
+ const inboundAt = Date.now();
510
+ setInboundActivity(accountId, inboundAt);
511
+ scheduleSave();
212
512
  },
213
513
  },
214
- replyOptions: {
215
- disableBlockStreaming: !effectiveReply.blockStreaming,
216
- shouldEmitToolResult: effectiveReply.allowTool ? () => true : undefined,
217
- },
218
514
  });
219
515
 
220
516
  return {
@@ -36,6 +36,10 @@ export function inboundDedupKey(params: {
36
36
  }
37
37
 
38
38
  export function resolveChatType(_route: BncrRoute): 'direct' | 'group' {
39
+ // Compatibility boundary: bncr currently records and dispatches all conversations as direct
40
+ // sessions even when the display scope contains a group id. Do not change this to true
41
+ // group semantics without updating session routing, reply target policy, and requireMention
42
+ // behavior together.
39
43
  return 'direct';
40
44
  }
41
45
 
@@ -45,6 +49,9 @@ export function parseBncrInboundParams(params: any) {
45
49
  const groupId = asString(params?.groupId || '0').trim() || '0';
46
50
  const userId = asString(params?.userId || '').trim();
47
51
  const sessionKeyfromroute = asString(params?.sessionKey || '').trim();
52
+ const providedOriginatingTo = asString(
53
+ params?.originatingTo || params?.providedOriginatingTo || params?.to || '',
54
+ ).trim() || undefined;
48
55
  const clientId = asString(params?.clientId || '').trim() || undefined;
49
56
 
50
57
  const route: BncrRoute = {
@@ -84,6 +91,7 @@ export function parseBncrInboundParams(params: any) {
84
91
  groupId,
85
92
  userId,
86
93
  sessionKeyfromroute,
94
+ providedOriginatingTo,
87
95
  clientId,
88
96
  route,
89
97
  text,