@xmoxmo/bncr 0.4.6 → 0.4.8

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 (35) hide show
  1. package/README.md +8 -0
  2. package/package.json +1 -1
  3. package/src/channel.ts +41 -2
  4. package/src/core/targets.ts +106 -17
  5. package/src/messaging/inbound/commands.ts +421 -51
  6. package/src/messaging/inbound/context-facts.ts +126 -14
  7. package/src/messaging/inbound/contracts.ts +24 -0
  8. package/src/messaging/inbound/dispatch-prep.ts +214 -39
  9. package/src/messaging/inbound/dispatch.ts +71 -5
  10. package/src/messaging/inbound/gate.ts +56 -86
  11. package/src/messaging/inbound/group-history.ts +189 -0
  12. package/src/messaging/inbound/native-command-runtime.ts +77 -61
  13. package/src/messaging/inbound/native-command.ts +192 -8
  14. package/src/messaging/inbound/parse.ts +113 -8
  15. package/src/messaging/inbound/reply-dispatch-serial.ts +62 -0
  16. package/src/messaging/inbound/reply-dispatch.ts +252 -77
  17. package/src/messaging/inbound/scene-admin.ts +300 -0
  18. package/src/messaging/inbound/session-label.ts +122 -13
  19. package/src/messaging/inbound/session-meta-task.ts +17 -0
  20. package/src/messaging/inbound/turn-context.ts +184 -71
  21. package/src/openclaw/channel-runtime-contracts.ts +1 -0
  22. package/src/plugin/channel-components.ts +35 -1
  23. package/src/plugin/channel-inbound-helpers.ts +9 -2
  24. package/src/plugin/channel-runtime-builders-delivery.ts +24 -1
  25. package/src/plugin/channel-runtime-types.ts +42 -0
  26. package/src/plugin/file-inbound-init.ts +27 -12
  27. package/src/plugin/file-inbound-runtime.ts +2 -0
  28. package/src/plugin/inbound-acceptance.ts +82 -1
  29. package/src/plugin/inbound-handlers.ts +97 -2
  30. package/src/plugin/inbound-surface-handlers-group.ts +16 -0
  31. package/src/plugin/messaging.ts +22 -5
  32. package/src/plugin/scene-registry.ts +171 -0
  33. package/src/plugin/state-store.ts +133 -0
  34. package/src/plugin/state-transient-runtime-group.ts +5 -0
  35. package/src/plugin/target-runtime.ts +2 -2
@@ -19,6 +19,27 @@ export type BncrStructuredContextFactsInput = {
19
19
  sender: {
20
20
  id: string;
21
21
  displayName?: string;
22
+ userId?: string;
23
+ userName?: string;
24
+ bridgeId?: string;
25
+ bridgeName?: string;
26
+ isAdmin?: boolean;
27
+ isOwner?: boolean;
28
+ isAuthorizedSender?: boolean;
29
+ role?: 'owner' | 'admin' | 'user';
30
+ };
31
+ platform?: string;
32
+ group?: {
33
+ id?: string;
34
+ name?: string;
35
+ isGroup?: boolean;
36
+ };
37
+ trigger?: {
38
+ shouldRespond?: boolean;
39
+ triggerKind?: string;
40
+ botName?: string;
41
+ isBotMentioned?: boolean;
42
+ isReplyToBot?: boolean;
22
43
  };
23
44
  message: {
24
45
  id?: string | null;
@@ -60,6 +81,27 @@ export function buildBncrStructuredContextFacts(input: BncrStructuredContextFact
60
81
  sender: {
61
82
  id: input.sender.id,
62
83
  displayName: input.sender.displayName || input.sender.id,
84
+ userId: input.sender.userId,
85
+ userName: input.sender.userName,
86
+ bridgeId: input.sender.bridgeId,
87
+ bridgeName: input.sender.bridgeName,
88
+ isAdmin: input.sender.isAdmin,
89
+ ...(input.sender.isOwner === true ? { isOwner: true } : {}),
90
+ ...(input.sender.isAuthorizedSender === true ? { isAuthorizedSender: true } : {}),
91
+ ...(input.sender.role !== undefined ? { role: input.sender.role } : {}),
92
+ },
93
+ platform: input.platform,
94
+ group: {
95
+ id: input.group?.id,
96
+ name: input.group?.name,
97
+ isGroup: input.group?.isGroup,
98
+ },
99
+ trigger: {
100
+ shouldRespond: input.trigger?.shouldRespond,
101
+ triggerKind: input.trigger?.triggerKind,
102
+ botName: input.trigger?.botName,
103
+ isBotMentioned: input.trigger?.isBotMentioned,
104
+ isReplyToBot: input.trigger?.isReplyToBot,
63
105
  },
64
106
  message: {
65
107
  id: input.message.id || undefined,
@@ -85,6 +127,15 @@ export function buildBncrPromptVisibleContextFacts(
85
127
  facts: ReturnType<typeof buildBncrStructuredContextFacts>,
86
128
  ) {
87
129
  const result: {
130
+ trigger?: {
131
+ botName?: string;
132
+ };
133
+ sender?: {
134
+ isAdmin?: boolean;
135
+ isOwner?: boolean;
136
+ isAuthorizedSender?: boolean;
137
+ role?: 'owner' | 'admin' | 'user';
138
+ };
88
139
  reply?: {
89
140
  to: string;
90
141
  originatingTo: string;
@@ -96,6 +147,30 @@ export function buildBncrPromptVisibleContextFacts(
96
147
  }>;
97
148
  } = {};
98
149
 
150
+ if (
151
+ facts.sender.isAdmin === true ||
152
+ facts.sender.isOwner === true ||
153
+ facts.sender.isAuthorizedSender === true ||
154
+ facts.sender.role === 'owner' ||
155
+ facts.sender.role === 'admin'
156
+ ) {
157
+ const sender = {
158
+ ...(facts.sender.isAdmin === true ? { isAdmin: true } : {}),
159
+ ...(facts.sender.isOwner === true ? { isOwner: true } : {}),
160
+ ...(facts.sender.isAuthorizedSender === true ? { isAuthorizedSender: true } : {}),
161
+ ...(facts.sender.role === 'owner' || facts.sender.role === 'admin'
162
+ ? { role: facts.sender.role }
163
+ : {}),
164
+ };
165
+ if (Object.keys(sender).length > 0) result.sender = sender;
166
+ }
167
+
168
+ if (facts.trigger.botName) {
169
+ result.trigger = {
170
+ botName: facts.trigger.botName,
171
+ };
172
+ }
173
+
99
174
  if (facts.reply.originatingTo !== facts.reply.to) {
100
175
  result.reply = {
101
176
  to: facts.reply.to,
@@ -125,11 +200,25 @@ export type BncrStructuredContextFactsFromInboundPartsInput = {
125
200
  channelId: string;
126
201
  parsed: {
127
202
  accountId: string;
203
+ platform?: string;
128
204
  peer: {
129
205
  kind: string;
130
206
  id: string;
131
207
  };
132
208
  clientId?: string;
209
+ bridgeId?: string;
210
+ bridgeName?: string;
211
+ groupId?: string;
212
+ groupName?: string;
213
+ userId?: string;
214
+ userName?: string;
215
+ isGroup?: boolean;
216
+ isAdmin?: boolean;
217
+ shouldRespond?: boolean;
218
+ triggerKind?: string;
219
+ botName?: string;
220
+ isBotMentioned?: boolean;
221
+ isReplyToBot?: boolean;
133
222
  msgId?: string;
134
223
  mimeType?: string;
135
224
  };
@@ -147,18 +236,24 @@ export type BncrStructuredContextFactsFromInboundPartsInput = {
147
236
  prepared: {
148
237
  rawBody: string;
149
238
  body?: string;
150
- mediaPath?: string | null;
151
- mediaContentType?: string;
239
+ mediaItems?: Array<{
240
+ path: string;
241
+ contentType?: string;
242
+ kind?: string;
243
+ }>;
152
244
  };
153
245
  senderIdForContext: string;
154
246
  senderDisplayName?: string;
247
+ bridgeSenderId?: string;
248
+ bridgeSenderName?: string;
249
+ senderIsOwner?: boolean;
250
+ senderIsAuthorized?: boolean;
155
251
  };
156
252
 
157
253
  export function buildBncrStructuredContextFactsFromInboundParts(
158
254
  input: BncrStructuredContextFactsFromInboundPartsInput,
159
255
  ) {
160
- const mediaPath = input.prepared.mediaPath || undefined;
161
- const mediaContentType = input.prepared.mediaContentType || input.parsed.mimeType;
256
+ const mediaItems = Array.isArray(input.prepared.mediaItems) ? input.prepared.mediaItems : [];
162
257
  return buildBncrStructuredContextFacts({
163
258
  channelId: input.channelId,
164
259
  accountId: input.parsed.accountId,
@@ -180,6 +275,27 @@ export function buildBncrStructuredContextFactsFromInboundParts(
180
275
  sender: {
181
276
  id: input.senderIdForContext,
182
277
  displayName: input.senderDisplayName,
278
+ userId: input.parsed.userId,
279
+ userName: input.parsed.userName,
280
+ bridgeId: input.bridgeSenderId || input.parsed.bridgeId || input.parsed.clientId,
281
+ bridgeName: input.bridgeSenderName || input.parsed.bridgeName,
282
+ isAdmin: input.parsed.isAdmin,
283
+ isOwner: input.senderIsOwner === true ? true : undefined,
284
+ isAuthorizedSender: input.senderIsAuthorized === true ? true : undefined,
285
+ role: input.senderIsOwner ? 'owner' : input.parsed.isAdmin ? 'admin' : undefined,
286
+ },
287
+ platform: input.parsed.platform,
288
+ group: {
289
+ id: input.parsed.groupId,
290
+ name: input.parsed.groupName,
291
+ isGroup: input.parsed.isGroup,
292
+ },
293
+ trigger: {
294
+ shouldRespond: input.parsed.shouldRespond,
295
+ triggerKind: input.parsed.triggerKind,
296
+ botName: input.parsed.botName,
297
+ isBotMentioned: input.parsed.isBotMentioned,
298
+ isReplyToBot: input.parsed.isReplyToBot,
183
299
  },
184
300
  message: {
185
301
  id: input.parsed.msgId,
@@ -188,15 +304,11 @@ export function buildBncrStructuredContextFactsFromInboundParts(
188
304
  commandBody: input.prepared.rawBody,
189
305
  envelopeBody: input.prepared.body,
190
306
  },
191
- media: mediaPath
192
- ? [
193
- {
194
- path: mediaPath,
195
- contentType: mediaContentType,
196
- kind: inferBncrStructuredMediaKind(mediaContentType),
197
- messageId: input.parsed.msgId,
198
- },
199
- ]
200
- : [],
307
+ media: mediaItems.map((item) => ({
308
+ path: item.path,
309
+ contentType: item.contentType,
310
+ kind: item.kind || inferBncrStructuredMediaKind(item.contentType),
311
+ messageId: input.parsed.msgId,
312
+ })),
201
313
  });
202
314
  }
@@ -15,21 +15,45 @@ export type BncrInboundParsedPeer = OpenClawChannelPeer;
15
15
 
16
16
  export type BncrInboundParamsInput = {
17
17
  accountId?: unknown;
18
+ protocolVersion?: unknown;
19
+ capabilities?: unknown;
18
20
  platform?: unknown;
19
21
  groupId?: unknown;
22
+ groupName?: unknown;
20
23
  userId?: unknown;
24
+ userName?: unknown;
21
25
  sessionKey?: unknown;
22
26
  originatingTo?: unknown;
23
27
  providedOriginatingTo?: unknown;
24
28
  to?: unknown;
25
29
  clientId?: unknown;
30
+ bridgeId?: unknown;
31
+ bridgeName?: unknown;
32
+ isGroup?: unknown;
33
+ isAdmin?: unknown;
26
34
  msg?: unknown;
27
35
  type?: unknown;
28
36
  base64?: unknown;
29
37
  path?: unknown;
38
+ paths?: unknown;
39
+ mediaList?: unknown;
30
40
  mimeType?: unknown;
31
41
  fileName?: unknown;
32
42
  msgId?: unknown;
43
+ shouldRespond?: unknown;
44
+ triggerKind?: unknown;
45
+ botName?: unknown;
46
+ isBotMentioned?: unknown;
47
+ isReplyToBot?: unknown;
48
+ };
49
+
50
+ export type BncrInboundMediaItem = {
51
+ path?: string;
52
+ base64?: string;
53
+ mimeType?: string;
54
+ fileName?: string;
55
+ type?: string;
56
+ transferId?: string;
33
57
  };
34
58
 
35
59
  export type BncrRememberSessionRoute = (
@@ -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
  }