@xmoxmo/bncr 0.2.5 → 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.
@@ -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,34 +10,88 @@ 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
- async function prepareBncrInboundSessionContext(args: {
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
88
  cfg: any;
15
89
  channelId: string;
16
90
  parsed: ParsedInbound;
17
91
  canonicalAgentId: string;
18
- rememberSessionRoute: (sessionKey: string, accountId: string, route: any) => void;
19
92
  }) {
20
- const { api, cfg, channelId, parsed, canonicalAgentId, rememberSessionRoute } = args;
21
- const {
22
- accountId,
23
- route,
24
- peer,
25
- sessionKeyfromroute,
26
- text,
27
- msgType,
28
- mediaBase64,
29
- mediaPathFromTransfer,
30
- mimeType,
31
- fileName,
32
- extracted,
33
- platform,
34
- groupId,
35
- userId,
36
- } = parsed;
93
+ const { api, cfg, channelId, parsed, canonicalAgentId } = args;
94
+ const { accountId, route, peer, sessionKeyfromroute, providedOriginatingTo, extracted } = parsed;
37
95
 
38
96
  const resolvedRoute = api.runtime.channel.routing.resolveAgentRoute({
39
97
  cfg,
@@ -45,9 +103,46 @@ async function prepareBncrInboundSessionContext(args: {
45
103
  const baseSessionKey =
46
104
  normalizeInboundSessionKey(sessionKeyfromroute, route, canonicalAgentId) ||
47
105
  resolvedRoute.sessionKey;
48
- const agentText = extracted.text;
49
106
  const taskSessionKey = withTaskSessionKey(baseSessionKey, extracted.taskKey);
50
- const sessionKey = taskSessionKey || baseSessionKey;
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 {
135
+ msgType,
136
+ mediaBase64,
137
+ mediaPathFromTransfer,
138
+ mimeType,
139
+ fileName,
140
+ extracted,
141
+ platform,
142
+ groupId,
143
+ userId,
144
+ } = parsed;
145
+ const { accountId, route, resolvedRoute, baseSessionKey, taskSessionKey, dispatchSessionKey } = resolution;
51
146
 
52
147
  rememberSessionRoute(baseSessionKey, accountId, route);
53
148
  if (taskSessionKey && taskSessionKey !== baseSessionKey) {
@@ -60,7 +155,7 @@ async function prepareBncrInboundSessionContext(args: {
60
155
 
61
156
  let mediaPath: string | undefined;
62
157
  if (mediaBase64) {
63
- const mediaBuf = Buffer.from(mediaBase64, 'base64');
158
+ const mediaBuf = decodeInboundMediaBase64(mediaBase64);
64
159
  const saved = await api.runtime.channel.media.saveMediaBuffer(
65
160
  mediaBuf,
66
161
  mimeType,
@@ -73,30 +168,159 @@ async function prepareBncrInboundSessionContext(args: {
73
168
  mediaPath = mediaPathFromTransfer;
74
169
  }
75
170
 
76
- const rawBody = agentText || (msgType === 'text' ? '' : `[${msgType}]`);
171
+ const rawBody = extracted.text || (msgType === 'text' ? '' : `[${msgType}]`);
77
172
  const body = api.runtime.channel.reply.formatAgentEnvelope({
78
173
  channel: 'Bncr',
79
174
  from: `${platform}:${groupId}:${userId}`,
80
175
  timestamp: Date.now(),
81
176
  previousTimestamp: api.runtime.channel.session.readSessionUpdatedAt({
82
177
  storePath,
83
- sessionKey,
178
+ sessionKey: dispatchSessionKey,
84
179
  }),
85
180
  envelope: api.runtime.channel.reply.resolveEnvelopeFormatOptions(cfg),
86
181
  body: rawBody,
87
182
  });
88
183
 
89
- const displayTo = formatDisplayScope(route);
90
184
  return {
91
- resolvedRoute,
92
- baseSessionKey,
93
- taskSessionKey,
94
- sessionKey,
95
185
  storePath,
96
186
  mediaPath,
97
187
  rawBody,
98
188
  body,
99
- displayTo,
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,
100
324
  };
101
325
  }
102
326
 
@@ -130,7 +354,7 @@ export async function dispatchBncrInbound(params: {
130
354
  scheduleSave,
131
355
  logger,
132
356
  } = params;
133
- const { accountId, route, clientId, msgId, extracted, mimeType, peer } = parsed;
357
+ const { accountId, clientId, msgId, extracted, mimeType, peer } = parsed;
134
358
 
135
359
  const nativeCommand = await handleBncrNativeCommand({
136
360
  api,
@@ -142,7 +366,7 @@ export async function dispatchBncrInbound(params: {
142
366
  enqueueFromReply,
143
367
  logger,
144
368
  });
145
- if (nativeCommand.handled) {
369
+ if (nativeCommand.handled && !nativeCommand.fallbackToAgent) {
146
370
  const inboundAt = Date.now();
147
371
  setInboundActivity(accountId, inboundAt);
148
372
  scheduleSave();
@@ -154,104 +378,138 @@ export async function dispatchBncrInbound(params: {
154
378
  };
155
379
  }
156
380
 
157
- const {
158
- resolvedRoute,
159
- sessionKey,
160
- storePath,
161
- mediaPath,
162
- rawBody,
163
- body,
164
- displayTo,
165
- } = await prepareBncrInboundSessionContext({
381
+ const resolution = resolveBncrInboundConversation({
166
382
  api,
167
383
  cfg,
168
384
  channelId,
169
385
  parsed,
170
386
  canonicalAgentId,
387
+ });
388
+ const { resolvedRoute, canonicalTo, dispatchSessionKey: sessionKey } = resolution;
389
+ const prepared = await prepareBncrInboundSessionContext({
390
+ api,
391
+ cfg,
392
+ parsed,
393
+ resolution,
171
394
  rememberSessionRoute,
172
395
  });
396
+ const { storePath, mediaPath, rawBody, body } = prepared;
397
+ const replyRouteFact = buildBncrInboundReplyRouteFact(resolution);
173
398
  if (!clientId) {
174
- emitBncrLogLine('warn', '[bncr] inbound missing clientId for chat identity');
175
- return {
176
- accountId,
177
- sessionKey,
178
- taskKey: extracted.taskKey ?? null,
179
- msgId: msgId ?? null,
180
- };
399
+ emitBncrLogLine(
400
+ 'warn',
401
+ '[bncr] inbound missing clientId for chat identity; using route identity fallback',
402
+ );
181
403
  }
182
- const senderIdForContext = clientId;
183
- const senderDisplayName = 'bncr-client';
184
- const ctxPayload = api.runtime.channel.reply.finalizeInboundContext({
185
- Body: body,
186
- BodyForAgent: rawBody,
187
- RawBody: rawBody,
188
- CommandBody: rawBody,
189
- MediaPath: mediaPath,
190
- MediaType: mimeType,
191
- From: senderIdForContext,
192
- To: displayTo,
193
- SessionKey: sessionKey,
194
- AccountId: accountId,
195
- ChatType: peer.kind,
196
- ConversationLabel: displayTo,
197
- SenderId: senderIdForContext,
198
- SenderName: senderDisplayName,
199
- SenderUsername: senderDisplayName,
200
- Provider: channelId,
201
- Surface: channelId,
202
- MessageSid: msgId,
203
- Timestamp: Date.now(),
204
- OriginatingChannel: channelId,
205
- OriginatingTo: displayTo,
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,
206
417
  });
207
418
 
208
- await api.runtime.channel.session.recordInboundSession({
209
- storePath,
210
- sessionKey,
211
- ctx: ctxPayload,
212
- onRecordError: (err: unknown) => {
213
- emitBncrLogLine('warn', `[bncr] inbound record session failed: ${String(err)}`);
214
- },
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,
215
435
  });
216
436
 
217
- const inboundAt = Date.now();
218
- setInboundActivity(accountId, inboundAt);
219
- scheduleSave();
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)}`);
464
+ },
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;
220
482
 
221
- const effectiveReply = buildBncrReplyConfig(cfg);
483
+ if (kind === 'tool' && !shouldForwardTool) {
484
+ return;
485
+ }
222
486
 
223
- await api.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
224
- ctx: ctxPayload,
225
- cfg: effectiveReply.replyCfg,
226
- dispatcherOptions: {
227
- deliver: async (
228
- payload: { text?: string; mediaUrl?: string; mediaUrls?: string[]; audioAsVoice?: boolean },
229
- info?: { kind?: 'tool' | 'block' | 'final' },
230
- ) => {
231
- const kind = info?.kind;
232
- const shouldForwardTool = effectiveReply.blockStreaming && effectiveReply.allowTool;
233
-
234
- if (kind === 'tool' && !shouldForwardTool) {
235
- return;
236
- }
237
-
238
- await enqueueFromReply({
239
- accountId,
240
- sessionKey,
241
- route,
242
- payload: {
243
- ...payload,
244
- kind: kind as 'tool' | 'block' | 'final' | undefined,
245
- },
246
- });
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();
247
512
  },
248
- onError: (err: unknown) => {
249
- emitBncrLogLine('error', `[bncr] outbound reply failed: ${String(err)}`);
250
- },
251
- },
252
- replyOptions: {
253
- disableBlockStreaming: !effectiveReply.blockStreaming,
254
- shouldEmitToolResult: effectiveReply.allowTool ? () => true : undefined,
255
513
  },
256
514
  });
257
515
 
@@ -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,