@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
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import { hasControlCommand } from 'openclaw/plugin-sdk/command-auth';
2
3
  import {
3
4
  formatDisplayScope,
4
5
  normalizeInboundSessionKey,
@@ -19,6 +20,7 @@ import {
19
20
  } from '../../openclaw/routing-runtime.ts';
20
21
  import type { BncrInboundApi, BncrInboundConfig, BncrRememberSessionRoute } from './contracts.ts';
21
22
  import { INBOUND_MEDIA_URL_MAX_BYTES, isHttpMediaUrl } from './media-url-download.ts';
23
+ import { parseBncrNativeCommand } from './native-command.ts';
22
24
  import { loadInboundRemoteMedia } from './remote-media.ts';
23
25
 
24
26
  export type ParsedInbound = ReturnType<typeof import('./parse.ts')['parseBncrInboundParams']>;
@@ -27,6 +29,7 @@ export type BncrInboundConversationResolution = {
27
29
  accountId: string;
28
30
  chatType: 'direct' | 'group';
29
31
  route: ParsedInbound['route'];
32
+ originalResolvedRouteSessionKey: string;
30
33
  resolvedRoute: {
31
34
  sessionKey: string;
32
35
  agentId: string;
@@ -51,6 +54,13 @@ export type BncrInboundReplyRouteFact = {
51
54
 
52
55
  export type BncrPreparedInboundSessionContext = {
53
56
  storePath: string;
57
+ mediaItems: Array<{
58
+ path: string;
59
+ contentType?: string;
60
+ fileName?: string;
61
+ kind: 'image' | 'video' | 'audio' | 'document';
62
+ transferId?: string;
63
+ }>;
54
64
  mediaPath?: string;
55
65
  mediaContentType?: string;
56
66
  rawBody: string;
@@ -63,10 +73,27 @@ export type BncrInboundPreparation = {
63
73
  replyRouteFact: BncrInboundReplyRouteFact;
64
74
  senderIdForContext: string;
65
75
  senderDisplayName: string;
76
+ ownerAllowFrom?: string[];
77
+ bridgeSenderId?: string;
78
+ bridgeSenderName?: string;
66
79
  };
67
80
 
68
81
  const INBOUND_MEDIA_MAX_BYTES = 30 * 1024 * 1024;
69
82
 
83
+ const BNCR_GENERIC_MEDIA_TEXTS = new Set([
84
+ '收到媒体文件',
85
+ '收到图片',
86
+ '收到视频',
87
+ '收到语音',
88
+ '收到音频',
89
+ '收到文件',
90
+ '[图片]',
91
+ '[视频]',
92
+ '[语音]',
93
+ '[音频]',
94
+ '[文件]',
95
+ ]);
96
+
70
97
  function assertResolvedAgentRoute(resolvedRoute: OpenClawResolvedAgentRoute): {
71
98
  sessionKey: string;
72
99
  agentId: string;
@@ -90,14 +117,140 @@ function formatRawBncrInboundTarget(route: ParsedInbound['route']): string {
90
117
  return `Bncr:${String(route.platform || '').trim()}:${String(route.groupId || '').trim()}:${String(route.userId || '').trim()}`;
91
118
  }
92
119
 
120
+ function inferBncrInboundMediaKind(args: {
121
+ msgType?: string;
122
+ mediaContentType?: string;
123
+ }): 'image' | 'video' | 'audio' | 'document' {
124
+ const msgType = String(args.msgType || '')
125
+ .trim()
126
+ .toLowerCase();
127
+ const contentType = String(args.mediaContentType || '')
128
+ .trim()
129
+ .toLowerCase();
130
+ if (msgType === 'image' || contentType.startsWith('image/')) return 'image';
131
+ if (msgType === 'video' || contentType.startsWith('video/')) return 'video';
132
+ if (msgType === 'audio' || msgType === 'voice' || contentType.startsWith('audio/'))
133
+ return 'audio';
134
+ return 'document';
135
+ }
136
+
137
+ function isBncrGenericMediaText(value: string): boolean {
138
+ const normalized = String(value || '').trim();
139
+ if (!normalized) return true;
140
+ return BNCR_GENERIC_MEDIA_TEXTS.has(normalized);
141
+ }
142
+
143
+ function resolveBncrInboundRawBody(args: {
144
+ extractedText?: string;
145
+ msgType?: string;
146
+ mediaItems?: Array<{ contentType?: string; kind?: string }>;
147
+ }) {
148
+ const text = String(args.extractedText || '').trim();
149
+ if (
150
+ String(args.msgType || 'text')
151
+ .trim()
152
+ .toLowerCase() === 'text'
153
+ )
154
+ return text;
155
+ if (text && !isBncrGenericMediaText(text)) return text;
156
+ const items = Array.isArray(args.mediaItems) ? args.mediaItems : [];
157
+ if (items.length === 0) {
158
+ const mediaKind = inferBncrInboundMediaKind({
159
+ msgType: args.msgType,
160
+ mediaContentType: undefined,
161
+ });
162
+ return `<media:${mediaKind}>`;
163
+ }
164
+ const kinds = items
165
+ .map((item) =>
166
+ String(item?.kind || '')
167
+ .trim()
168
+ .toLowerCase(),
169
+ )
170
+ .filter(Boolean);
171
+ const contentTypes = items.map((item) => String(item?.contentType || '').trim()).filter(Boolean);
172
+ const firstKind =
173
+ kinds[0] ||
174
+ inferBncrInboundMediaKind({
175
+ msgType: args.msgType,
176
+ mediaContentType: contentTypes[0],
177
+ });
178
+ const uniformKind =
179
+ kinds.length > 0 && kinds.every((candidate) => candidate === firstKind)
180
+ ? firstKind
181
+ : 'document';
182
+ const count = Math.max(items.length, 1);
183
+ if (count <= 1) return `<media:${uniformKind}>`;
184
+ if (uniformKind === 'image') return `<media:image> (${count} images)`;
185
+ if (uniformKind === 'video') return `<media:video> (${count} videos)`;
186
+ if (uniformKind === 'audio') return `<media:audio> (${count} audio attachments)`;
187
+ return `<media:document> (${count} attachments)`;
188
+ }
189
+
190
+ async function saveBncrInboundMediaItem(args: {
191
+ api: BncrInboundApi;
192
+ item: ParsedInbound['mediaItems'][number];
193
+ }) {
194
+ const pathFromTransfer = String(args.item?.path || '').trim();
195
+ const base64 = String(args.item?.base64 || '').trim();
196
+ const mimeType = String(args.item?.mimeType || '').trim() || undefined;
197
+ const fileName = String(args.item?.fileName || '').trim() || undefined;
198
+ const transferId = String(args.item?.transferId || '').trim() || undefined;
199
+ let mediaPath: string | undefined;
200
+ let mediaContentType = mimeType;
201
+ if (base64) {
202
+ const mediaBuf = decodeInboundMediaBase64(base64);
203
+ const saved = await saveOpenClawChannelMediaBuffer(
204
+ args.api,
205
+ mediaBuf,
206
+ mimeType,
207
+ 'inbound',
208
+ 30 * 1024 * 1024,
209
+ fileName,
210
+ );
211
+ mediaPath = saved.path;
212
+ } else if (pathFromTransfer && isHttpMediaUrl(pathFromTransfer)) {
213
+ const downloaded = await loadInboundRemoteMedia(
214
+ args.api,
215
+ pathFromTransfer,
216
+ INBOUND_MEDIA_URL_MAX_BYTES,
217
+ );
218
+ mediaContentType = downloaded.contentType || mimeType;
219
+ const saved = await saveOpenClawChannelMediaBuffer(
220
+ args.api,
221
+ downloaded.buffer,
222
+ mediaContentType,
223
+ 'inbound',
224
+ INBOUND_MEDIA_URL_MAX_BYTES,
225
+ fileName,
226
+ );
227
+ mediaPath = saved.path;
228
+ } else if (pathFromTransfer && fs.existsSync(pathFromTransfer)) {
229
+ mediaPath = pathFromTransfer;
230
+ }
231
+ if (!mediaPath) return null;
232
+ const kind = inferBncrInboundMediaKind({
233
+ msgType: String(args.item?.type || '').trim(),
234
+ mediaContentType: mediaContentType || mimeType,
235
+ });
236
+ return {
237
+ path: mediaPath,
238
+ ...(mediaContentType ? { contentType: mediaContentType } : {}),
239
+ ...(fileName ? { fileName } : {}),
240
+ kind,
241
+ ...(transferId ? { transferId } : {}),
242
+ };
243
+ }
244
+
93
245
  export function resolveBncrInboundConversation(args: {
94
246
  api: BncrInboundApi;
95
247
  cfg: BncrInboundConfig;
96
248
  channelId: string;
97
249
  parsed: ParsedInbound;
98
250
  canonicalAgentId: string;
251
+ resolvedAgentId?: string;
99
252
  }) {
100
- const { api, cfg, channelId, parsed, canonicalAgentId } = args;
253
+ const { api, cfg, channelId, parsed, canonicalAgentId, resolvedAgentId } = args;
101
254
  const { accountId, route, peer, sessionKeyfromroute, providedOriginatingTo, extracted } = parsed;
102
255
 
103
256
  const resolvedRoute = assertResolvedAgentRoute(
@@ -109,8 +262,9 @@ export function resolveBncrInboundConversation(args: {
109
262
  }),
110
263
  );
111
264
 
265
+ const targetAgentId = (resolvedAgentId || '').trim() || resolvedRoute.agentId || canonicalAgentId;
112
266
  const baseSessionKey =
113
- normalizeInboundSessionKey(sessionKeyfromroute, route, canonicalAgentId) ||
267
+ normalizeInboundSessionKey(sessionKeyfromroute, route, targetAgentId) ||
114
268
  resolvedRoute.sessionKey;
115
269
  const taskSessionKey = withTaskSessionKey(baseSessionKey, extracted.taskKey);
116
270
  const dispatchSessionKey = taskSessionKey || baseSessionKey;
@@ -122,7 +276,12 @@ export function resolveBncrInboundConversation(args: {
122
276
  accountId,
123
277
  chatType: peer.kind,
124
278
  route,
125
- resolvedRoute,
279
+ originalResolvedRouteSessionKey: resolvedRoute.sessionKey,
280
+ resolvedRoute: {
281
+ ...resolvedRoute,
282
+ agentId: targetAgentId || resolvedRoute.agentId,
283
+ sessionKey: baseSessionKey || resolvedRoute.sessionKey,
284
+ },
126
285
  canonicalTo,
127
286
  rawTo,
128
287
  originatingTo,
@@ -198,40 +357,38 @@ export async function prepareBncrInboundSessionContext(args: {
198
357
  agentId: resolvedRoute.agentId,
199
358
  });
200
359
 
201
- let mediaPath: string | undefined;
202
- let mediaContentType = mimeType;
203
- if (mediaBase64) {
204
- const mediaBuf = decodeInboundMediaBase64(mediaBase64);
205
- const saved = await saveOpenClawChannelMediaBuffer(
206
- api,
207
- mediaBuf,
208
- mimeType,
209
- 'inbound',
210
- 30 * 1024 * 1024,
211
- fileName,
212
- );
213
- mediaPath = saved.path;
214
- } else if (mediaPathFromTransfer && isHttpMediaUrl(mediaPathFromTransfer)) {
215
- const downloaded = await loadInboundRemoteMedia(
216
- api,
217
- mediaPathFromTransfer,
218
- INBOUND_MEDIA_URL_MAX_BYTES,
219
- );
220
- mediaContentType = downloaded.contentType || mimeType;
221
- const saved = await saveOpenClawChannelMediaBuffer(
222
- api,
223
- downloaded.buffer,
224
- mediaContentType,
225
- 'inbound',
226
- INBOUND_MEDIA_URL_MAX_BYTES,
227
- fileName,
228
- );
229
- mediaPath = saved.path;
230
- } else if (mediaPathFromTransfer && fs.existsSync(mediaPathFromTransfer)) {
231
- mediaPath = mediaPathFromTransfer;
232
- }
360
+ const parsedMediaItems = Array.isArray(parsed.mediaItems) ? parsed.mediaItems : [];
361
+ const fallbackMediaItems =
362
+ parsedMediaItems.length > 0
363
+ ? parsedMediaItems
364
+ : [
365
+ {
366
+ ...(mediaPathFromTransfer ? { path: mediaPathFromTransfer } : {}),
367
+ ...(mediaBase64 ? { base64: mediaBase64 } : {}),
368
+ ...(mimeType ? { mimeType } : {}),
369
+ ...(fileName ? { fileName } : {}),
370
+ ...(msgType ? { type: msgType } : {}),
371
+ },
372
+ ];
373
+ const mediaItems = (
374
+ await Promise.all(
375
+ fallbackMediaItems.map((item) =>
376
+ saveBncrInboundMediaItem({
377
+ api,
378
+ item,
379
+ }),
380
+ ),
381
+ )
382
+ ).filter((item): item is NonNullable<typeof item> => Boolean(item));
383
+ const primaryMedia = mediaItems[0];
384
+ const mediaPath = primaryMedia?.path;
385
+ const mediaContentType = primaryMedia?.contentType || mimeType;
233
386
 
234
- const rawBody = extracted.text || (msgType === 'text' ? '' : `[${msgType}]`);
387
+ const rawBody = resolveBncrInboundRawBody({
388
+ extractedText: extracted.text,
389
+ msgType,
390
+ mediaItems,
391
+ });
235
392
  const body = formatOpenClawAgentEnvelope(api, {
236
393
  channel: 'Bncr',
237
394
  from: `${platform}:${groupId}:${userId}`,
@@ -246,6 +403,7 @@ export async function prepareBncrInboundSessionContext(args: {
246
403
 
247
404
  return {
248
405
  storePath,
406
+ mediaItems,
249
407
  mediaPath,
250
408
  mediaContentType,
251
409
  rawBody,
@@ -272,15 +430,18 @@ export async function prepareBncrInboundDispatch(args: {
272
430
  cfg: BncrInboundConfig;
273
431
  parsed: ParsedInbound;
274
432
  canonicalAgentId: string;
433
+ resolvedAgentId?: string;
275
434
  rememberSessionRoute: BncrRememberSessionRoute;
276
435
  }) {
277
- const { api, channelId, cfg, parsed, canonicalAgentId, rememberSessionRoute } = args;
436
+ const { api, channelId, cfg, parsed, canonicalAgentId, resolvedAgentId, rememberSessionRoute } =
437
+ args;
278
438
  const resolution = resolveBncrInboundConversation({
279
439
  api,
280
440
  cfg,
281
441
  channelId,
282
442
  parsed,
283
443
  canonicalAgentId,
444
+ resolvedAgentId,
284
445
  });
285
446
  const prepared = await prepareBncrInboundSessionContext({
286
447
  api,
@@ -290,8 +451,19 @@ export async function prepareBncrInboundDispatch(args: {
290
451
  rememberSessionRoute,
291
452
  });
292
453
  const replyRouteFact = buildBncrInboundReplyRouteFact(resolution);
293
- const senderIdForContext = parsed.clientId || resolution.canonicalTo;
294
- const senderDisplayName = parsed.clientId ? 'bncr-client' : resolution.canonicalTo;
454
+ const senderIdForContext = parsed.userId || parsed.clientId || resolution.canonicalTo;
455
+ const senderDisplayName = parsed.userName || resolution.canonicalTo;
456
+ const isBncrNativeCommand =
457
+ parseBncrNativeCommand(parsed.extracted.text, {
458
+ allowBareWhoami: parsed.isAdmin !== true,
459
+ }) !== null;
460
+ const isOpenClawNativeCommand =
461
+ !isBncrNativeCommand &&
462
+ hasControlCommand(parsed.extracted.text, cfg as Parameters<typeof hasControlCommand>[1]);
463
+ const ownerAllowFrom =
464
+ parsed.isAdmin === true && isOpenClawNativeCommand && senderIdForContext
465
+ ? [senderIdForContext]
466
+ : undefined;
295
467
 
296
468
  return {
297
469
  resolution,
@@ -299,5 +471,8 @@ export async function prepareBncrInboundDispatch(args: {
299
471
  replyRouteFact,
300
472
  senderIdForContext,
301
473
  senderDisplayName,
474
+ ...(ownerAllowFrom ? { ownerAllowFrom } : {}),
475
+ bridgeSenderId: parsed.bridgeId || parsed.clientId,
476
+ bridgeSenderName: parsed.bridgeName || 'Bncr',
302
477
  } satisfies BncrInboundPreparation;
303
478
  }
@@ -1,4 +1,5 @@
1
1
  import { emitBncrLogLine } from '../../core/logging.ts';
2
+ import type { BncrSceneRecord } from '../../plugin/channel-runtime-types.ts';
2
3
  import { handleBncrNativeCommand } from './commands.ts';
3
4
  import type {
4
5
  BncrEnqueueFromReply,
@@ -15,6 +16,12 @@ import {
15
16
  prepareBncrInboundDispatch,
16
17
  resolveBncrInboundConversation,
17
18
  } from './dispatch-prep.ts';
19
+ import {
20
+ type BncrGroupHistoryMap,
21
+ clearBncrPendingGroupHistory,
22
+ recordBncrPendingGroupMedia,
23
+ recordBncrPendingGroupText,
24
+ } from './group-history.ts';
18
25
  import { runBncrInboundReplyDispatch } from './reply-dispatch.ts';
19
26
  import { buildBncrInboundTurnContext } from './turn-context.ts';
20
27
 
@@ -31,6 +38,14 @@ export async function dispatchBncrInbound(params: {
31
38
  cfg: BncrInboundConfig;
32
39
  parsed: ParsedInbound;
33
40
  canonicalAgentId: string;
41
+ shouldDispatch?: boolean;
42
+ shouldAccumulate?: boolean;
43
+ resolvedAgentId?: string;
44
+ sceneRegistry: Map<string, BncrSceneRecord>;
45
+ groupHistories: BncrGroupHistoryMap;
46
+ defaultAdminAgentId: string;
47
+ defaultPublicAgentId: string;
48
+ now: () => number;
34
49
  rememberSessionRoute: BncrRememberSessionRoute;
35
50
  enqueueFromReply: BncrEnqueueFromReply;
36
51
  setInboundActivity: (accountId: string, at: number) => void;
@@ -43,6 +58,14 @@ export async function dispatchBncrInbound(params: {
43
58
  cfg,
44
59
  parsed,
45
60
  canonicalAgentId,
61
+ shouldDispatch = true,
62
+ shouldAccumulate = shouldDispatch,
63
+ resolvedAgentId,
64
+ sceneRegistry,
65
+ groupHistories = new Map(),
66
+ defaultAdminAgentId,
67
+ defaultPublicAgentId,
68
+ now,
46
69
  rememberSessionRoute,
47
70
  enqueueFromReply,
48
71
  setInboundActivity,
@@ -57,6 +80,11 @@ export async function dispatchBncrInbound(params: {
57
80
  cfg,
58
81
  parsed,
59
82
  canonicalAgentId,
83
+ resolvedAgentId,
84
+ sceneRegistry,
85
+ defaultAdminAgentId,
86
+ defaultPublicAgentId,
87
+ now,
60
88
  rememberSessionRoute,
61
89
  enqueueFromReply,
62
90
  logger,
@@ -79,12 +107,23 @@ export async function dispatchBncrInbound(params: {
79
107
  cfg,
80
108
  parsed,
81
109
  canonicalAgentId,
110
+ resolvedAgentId,
82
111
  rememberSessionRoute,
83
112
  });
84
- const { resolution, prepared, replyRouteFact, senderIdForContext, senderDisplayName } =
85
- preparedDispatch;
113
+ const {
114
+ resolution,
115
+ prepared,
116
+ replyRouteFact,
117
+ senderIdForContext,
118
+ senderDisplayName,
119
+ ownerAllowFrom,
120
+ bridgeSenderId,
121
+ bridgeSenderName,
122
+ } = preparedDispatch;
86
123
  const { dispatchSessionKey: sessionKey } = resolution;
87
- const { storePath, mediaPath, mediaContentType, rawBody } = prepared;
124
+ const { storePath, mediaItems, rawBody } = prepared;
125
+ const primaryMedia = mediaItems[0];
126
+ const mediaContentType = primaryMedia?.contentType;
88
127
  if (!clientId) {
89
128
  emitBncrLogLine(
90
129
  'warn',
@@ -93,18 +132,39 @@ export async function dispatchBncrInbound(params: {
93
132
  }
94
133
  const ctxPayload = await buildBncrInboundTurnContext({
95
134
  api,
135
+ cfg,
96
136
  channelId,
97
137
  parsed,
98
138
  msgId,
99
- mimeType: mediaContentType || mimeType,
100
- mediaPath,
101
139
  peer,
102
140
  senderIdForContext,
103
141
  senderDisplayName,
142
+ ownerAllowFrom,
143
+ bridgeSenderId,
144
+ bridgeSenderName,
104
145
  resolution,
105
146
  prepared,
147
+ groupHistories,
148
+ shouldDispatch,
106
149
  });
107
150
 
151
+ if (!shouldDispatch && shouldAccumulate) {
152
+ recordBncrPendingGroupText({
153
+ historyMap: groupHistories,
154
+ parsed,
155
+ senderDisplayName,
156
+ bodyText: rawBody,
157
+ });
158
+ await recordBncrPendingGroupMedia({
159
+ historyMap: groupHistories,
160
+ parsed,
161
+ senderDisplayName,
162
+ bodyText: rawBody,
163
+ mediaItems,
164
+ mediaContentType: mediaContentType || mimeType,
165
+ });
166
+ }
167
+
108
168
  await runBncrInboundReplyDispatch({
109
169
  api,
110
170
  channelId,
@@ -118,11 +178,17 @@ export async function dispatchBncrInbound(params: {
118
178
  resolution,
119
179
  replyRouteFact,
120
180
  senderIdForContext,
181
+ senderDisplayName,
182
+ shouldDispatch,
121
183
  setInboundActivity,
122
184
  scheduleSave,
123
185
  enqueueFromReply,
124
186
  });
125
187
 
188
+ if (shouldDispatch) {
189
+ clearBncrPendingGroupHistory({ historyMap: groupHistories, parsed });
190
+ }
191
+
126
192
  return {
127
193
  accountId,
128
194
  sessionKey,
@@ -1,59 +1,46 @@
1
1
  import { normalizeAccountId } from '../../core/accounts.ts';
2
- import { resolveBncrChannelPolicy } from '../../core/policy.ts';
3
- import { buildDisplayScopeCandidates } from '../../core/targets.ts';
4
- import type { BncrRoute } from '../../core/types.ts';
5
- import {
6
- defineOpenClawStableChannelIngressIdentity,
7
- resolveOpenClawChannelMessageIngress,
8
- } from '../../openclaw/ingress-runtime.ts';
9
2
  import type { BncrInboundConfig, BncrInboundParamsInput } from './contracts.ts';
10
3
 
11
- type RouteLike = Partial<Pick<BncrRoute, 'groupId' | 'userId' | 'platform'>>;
12
- type AccessGroupsLike = Parameters<typeof resolveOpenClawChannelMessageIngress>[0]['accessGroups'];
13
-
14
4
  export type BncrGateResult = { allowed: true } | { allowed: false; reason: string };
15
5
 
6
+ const REQUIRED_PROTOCOL_VERSION = 'scene-routing-v1';
7
+ const REQUIRED_CAPABILITY = 'scene-routing-v1';
8
+
16
9
  function asString(v: unknown, fallback = ''): string {
17
10
  if (typeof v === 'string') return v;
18
11
  if (v == null) return fallback;
19
12
  return String(v);
20
13
  }
21
14
 
22
- const bncrIngressIdentity = defineOpenClawStableChannelIngressIdentity({
23
- key: 'displayScope',
24
- kind: 'plugin:bncr-display-scope',
25
- normalize: (value: string) => asString(value).trim() || null,
26
- sensitivity: 'pii',
27
- entryIdPrefix: 'bncr-allow',
28
- aliases: [
29
- {
30
- key: 'routeKey',
31
- kind: 'plugin:bncr-route-key',
32
- normalize: (value: string) => asString(value).trim() || null,
33
- sensitivity: 'pii',
34
- },
35
- ],
36
- });
15
+ function asBoolean(v: unknown, fallback = false): boolean {
16
+ if (typeof v === 'boolean') return v;
17
+ if (typeof v === 'number') return v !== 0;
18
+ if (typeof v === 'string') {
19
+ const raw = v.trim().toLowerCase();
20
+ if (!raw) return fallback;
21
+ if (['true', '1', 'yes', 'y', 'on'].includes(raw)) return true;
22
+ if (['false', '0', 'no', 'n', 'off'].includes(raw)) return false;
23
+ }
24
+ return fallback;
25
+ }
37
26
 
38
- function gateReasonFromIngress(reasonCode?: string): string {
39
- switch (reasonCode) {
40
- case 'dm_policy_disabled':
41
- return 'dm disabled';
42
- case 'dm_policy_not_allowlisted':
43
- case 'dm_policy_pairing_required':
44
- return 'dm allowlist blocked';
45
- case 'group_policy_disabled':
46
- return 'group disabled';
47
- case 'group_policy_not_allowlisted':
48
- case 'group_policy_empty_allowlist':
49
- return 'group allowlist blocked';
50
- default:
51
- return reasonCode || 'ingress blocked';
27
+ function asCapabilities(v: unknown): string[] {
28
+ if (Array.isArray(v)) {
29
+ return v.map((item) => asString(item).trim()).filter(Boolean);
30
+ }
31
+ if (typeof v === 'string') {
32
+ return v
33
+ .split(',')
34
+ .map((item) => item.trim())
35
+ .filter(Boolean);
52
36
  }
37
+ return [];
53
38
  }
54
39
 
55
40
  export async function checkBncrMessageGate(params: {
56
- parsed: BncrInboundParamsInput & { route?: RouteLike };
41
+ parsed: BncrInboundParamsInput & {
42
+ route?: Partial<{ groupId: string; userId: string; platform: string }>;
43
+ };
57
44
  cfg: BncrInboundConfig;
58
45
  account: { accountId: string; enabled?: boolean };
59
46
  }): Promise<BncrGateResult> {
@@ -61,57 +48,40 @@ export async function checkBncrMessageGate(params: {
61
48
  const accountId = normalizeAccountId(account?.accountId);
62
49
  const channelCfg = cfg?.channels?.bncr || {};
63
50
  const accountCfg = channelCfg?.accounts?.[accountId] || {};
64
- const policy = resolveBncrChannelPolicy(channelCfg);
65
-
66
- if (policy.enabled === false || account?.enabled === false || accountCfg?.enabled === false) {
51
+ if (
52
+ channelCfg?.enabled === false ||
53
+ account?.enabled === false ||
54
+ accountCfg?.enabled === false
55
+ ) {
67
56
  return { allowed: false, reason: 'account disabled' };
68
57
  }
69
58
 
70
- const route = parsed?.route;
71
- if (!route?.platform || !route?.groupId || !route?.userId) {
72
- return { allowed: false, reason: 'invalid route' };
59
+ const protocolVersion = asString(parsed?.protocolVersion || '').trim();
60
+ const capabilities = asCapabilities(parsed?.capabilities);
61
+ if (
62
+ protocolVersion !== REQUIRED_PROTOCOL_VERSION ||
63
+ !capabilities.includes(REQUIRED_CAPABILITY)
64
+ ) {
65
+ return { allowed: false, reason: 'client protocol outdated' };
73
66
  }
74
- const resolvedRoute: BncrRoute = {
75
- platform: route.platform,
76
- groupId: route.groupId,
77
- userId: route.userId,
78
- };
79
- const isGroup = asString(route?.groupId || '0') !== '0';
80
-
81
- const candidates = buildDisplayScopeCandidates(resolvedRoute);
82
- const displayScope = candidates[0] || '';
83
- const routeKey = candidates.find((candidate) => candidate !== displayScope) || displayScope;
84
67
 
85
- // requireMention 默认值为 false。
86
- // 设计目标:当它未来真正生效时,含义是“群消息只有在明确提到机器人时才允许进入处理链”。
87
- // 但当前 parse 层尚未稳定提取 mentions,上游客户端也未统一透传 mention 信号,
88
- // 因此现阶段即使配置为 true,也仍不做实际拦截,避免出现半实现状态。
89
- const resolved = await resolveOpenClawChannelMessageIngress({
90
- channelId: 'bncr',
91
- accountId,
92
- identity: bncrIngressIdentity,
93
- subject: {
94
- stableId: displayScope,
95
- aliases: { routeKey },
96
- },
97
- conversation: {
98
- kind: isGroup ? 'group' : 'direct',
99
- id: isGroup ? asString(route?.groupId) : asString(route?.userId || displayScope),
100
- },
101
- event: { kind: 'message', authMode: 'inbound', mayPair: !isGroup },
102
- policy: {
103
- dmPolicy: policy.dmPolicy,
104
- groupPolicy: policy.groupPolicy,
105
- groupAllowFromFallbackToAllowFrom: false,
106
- },
107
- allowFrom: policy.dmPolicy === 'open' ? ['*', ...policy.allowFrom] : policy.allowFrom,
108
- groupAllowFrom: policy.groupAllowFrom,
109
- accessGroups: cfg?.accessGroups as AccessGroupsLike,
110
- });
111
-
112
- if (resolved.ingress.admission === 'dispatch' || resolved.ingress.admission === 'observe') {
113
- return { allowed: true };
68
+ const platform = asString(parsed?.platform || '').trim();
69
+ const userId = asString(parsed?.userId || '').trim();
70
+ const clientId = asString(parsed?.clientId || '').trim();
71
+ const groupId = asString(parsed?.groupId || '').trim();
72
+ const isGroupProvided = parsed && Object.hasOwn(parsed, 'isGroup');
73
+ const isAdminProvided = parsed && Object.hasOwn(parsed, 'isAdmin');
74
+ const isGroup = asBoolean(parsed?.isGroup, false);
75
+ if (!platform || !userId || !clientId || !isGroupProvided || !isAdminProvided) {
76
+ return { allowed: false, reason: 'inbound schema incomplete' };
77
+ }
78
+ if (isGroup && !groupId) {
79
+ return { allowed: false, reason: 'inbound schema incomplete' };
114
80
  }
115
81
 
116
- return { allowed: false, reason: gateReasonFromIngress(resolved.ingress.reasonCode) };
82
+ const route = parsed?.route;
83
+ if (!route?.platform || !route?.groupId || !route?.userId) {
84
+ return { allowed: false, reason: 'invalid route' };
85
+ }
86
+ return { allowed: true };
117
87
  }