@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,13 +1,66 @@
1
+ import { hasControlCommand } from 'openclaw/plugin-sdk/command-auth';
1
2
  import { resolveAccount } from '../core/accounts.ts';
2
3
  import { checkBncrMessageGate } from '../messaging/inbound/gate.ts';
4
+ import { parseBncrNativeCommand } from '../messaging/inbound/native-command.ts';
3
5
  import type { parseBncrInboundParams } from '../messaging/inbound/parse.ts';
4
6
  import type { OpenClawResolvedAgentRoute } from '../openclaw/routing-runtime.ts';
5
7
  import type { buildInboundResponsePayload } from './channel-inbound-helpers.ts';
6
8
  import { resolveInboundSessionContext } from './channel-inbound-helpers.ts';
7
- import type { BncrChannelConfigRoot } from './channel-runtime-types.ts';
9
+ import type { BncrChannelConfigRoot, BncrSceneRecord } from './channel-runtime-types.ts';
10
+ import { decideSceneAdmission } from './scene-registry.ts';
8
11
 
9
12
  type InboundAcceptanceResponsePayload = ReturnType<typeof buildInboundResponsePayload>;
10
13
 
14
+ function shouldDispatchForScene(args: {
15
+ parsed: ReturnType<typeof parseBncrInboundParams>;
16
+ admission: ReturnType<typeof decideSceneAdmission>;
17
+ cfg: BncrChannelConfigRoot;
18
+ }) {
19
+ const { parsed, admission, cfg } = args;
20
+ if (parsed.peer.kind === 'direct') return true;
21
+ if (!admission.allowed) return false;
22
+
23
+ const isBncrNativeCommand =
24
+ parseBncrNativeCommand(parsed.extracted.text, {
25
+ allowBareWhoami: parsed.isAdmin !== true,
26
+ }) !== null;
27
+ const isAdminOpenClawNativeCommand =
28
+ parsed.isAdmin === true &&
29
+ !isBncrNativeCommand &&
30
+ hasControlCommand(parsed.extracted.text, cfg as Parameters<typeof hasControlCommand>[1]);
31
+ if (isAdminOpenClawNativeCommand) return true;
32
+
33
+ const mode = admission.scene.groupReplyMode || 'admin';
34
+ if (mode === 'all') return true;
35
+ if (mode === 'admin') return parsed.isAdmin === true;
36
+ if (mode === 'mention') return parsed.shouldRespond === true;
37
+ return parsed.isAdmin === true || parsed.shouldRespond === true;
38
+ }
39
+
40
+ function shouldAccumulateForScene(args: {
41
+ parsed: ReturnType<typeof parseBncrInboundParams>;
42
+ admission: ReturnType<typeof decideSceneAdmission>;
43
+ cfg: BncrChannelConfigRoot;
44
+ }) {
45
+ const { parsed, admission, cfg } = args;
46
+ if (parsed.peer.kind === 'direct') return true;
47
+ if (!admission.allowed) return false;
48
+
49
+ const isBncrNativeCommand =
50
+ parseBncrNativeCommand(parsed.extracted.text, {
51
+ allowBareWhoami: parsed.isAdmin !== true,
52
+ }) !== null;
53
+ const isAdminOpenClawNativeCommand =
54
+ parsed.isAdmin === true &&
55
+ !isBncrNativeCommand &&
56
+ hasControlCommand(parsed.extracted.text, cfg as Parameters<typeof hasControlCommand>[1]);
57
+ if (isAdminOpenClawNativeCommand) return true;
58
+
59
+ const mode = admission.scene.groupReplyMode || 'admin';
60
+ if (mode === 'admin') return parsed.isAdmin === true;
61
+ return true;
62
+ }
63
+
11
64
  export async function prepareBncrInboundAcceptance(args: {
12
65
  api: unknown;
13
66
  parsed: ReturnType<typeof parseBncrInboundParams>;
@@ -27,6 +80,10 @@ export async function prepareBncrInboundAcceptance(args: {
27
80
  | { kind: 'gate-denied'; accountId: string; msgId?: string | null; reason: string },
28
81
  ) => InboundAcceptanceResponsePayload;
29
82
  markInboundDedupSeen: (key: string) => boolean;
83
+ sceneRegistry: Map<string, BncrSceneRecord>;
84
+ now: () => number;
85
+ defaultAdminAgentId: string;
86
+ defaultPublicAgentId: string;
30
87
  }) {
31
88
  const { parsed, canonicalAgentId } = args;
32
89
  const {
@@ -83,6 +140,26 @@ export async function prepareBncrInboundAcceptance(args: {
83
140
  };
84
141
  }
85
142
 
143
+ const admission = decideSceneAdmission({
144
+ parsed,
145
+ now: args.now(),
146
+ sceneRegistry: args.sceneRegistry,
147
+ defaultAdminAgentId: args.defaultAdminAgentId,
148
+ defaultPublicAgentId: args.defaultPublicAgentId,
149
+ });
150
+ if (!admission.allowed) {
151
+ return {
152
+ ok: false as const,
153
+ status: admission.replyPolicy === 'pending',
154
+ payload: args.buildInboundResponsePayload({
155
+ kind: 'gate-denied',
156
+ accountId,
157
+ msgId: msgId ?? null,
158
+ reason: admission.reason,
159
+ }),
160
+ };
161
+ }
162
+
86
163
  const { sessionKey, inboundText } = resolveInboundSessionContext({
87
164
  cfg,
88
165
  accountId,
@@ -90,6 +167,7 @@ export async function prepareBncrInboundAcceptance(args: {
90
167
  route,
91
168
  sessionKeyFromRoute: sessionKeyfromroute,
92
169
  canonicalAgentId,
170
+ resolvedAgentId: admission.agentId,
93
171
  taskKey: extracted.taskKey ?? undefined,
94
172
  text,
95
173
  extractedText: extracted.text,
@@ -103,5 +181,8 @@ export async function prepareBncrInboundAcceptance(args: {
103
181
  sessionKey,
104
182
  inboundText,
105
183
  hasMedia: Boolean(mediaBase64 || mediaPathFromTransfer),
184
+ resolvedAgentId: admission.agentId,
185
+ shouldDispatch: shouldDispatchForScene({ parsed, admission, cfg }),
186
+ shouldAccumulate: shouldAccumulateForScene({ parsed, admission, cfg }),
106
187
  };
107
188
  }
@@ -1,11 +1,12 @@
1
1
  import type { GatewayRequestHandlerOptions } from 'openclaw/plugin-sdk/core';
2
2
  import type { BncrConnection, BncrRoute } from '../core/types.ts';
3
+ import type { BncrGroupHistoryMap } from '../messaging/inbound/group-history.ts';
3
4
  import type { parseBncrInboundParams } from '../messaging/inbound/parse.ts';
4
5
  import type {
5
6
  buildInboundResponsePayload,
6
7
  resolveInboundSessionContext,
7
8
  } from './channel-inbound-helpers.ts';
8
- import type { BncrChannelConfigRoot } from './channel-runtime-types.ts';
9
+ import type { BncrChannelConfigRoot, BncrSceneRecord } from './channel-runtime-types.ts';
9
10
  import { buildBncrGatewayEventContext } from './gateway-event-context.ts';
10
11
 
11
12
  type InboundLifecycleStage = 'accepted';
@@ -39,6 +40,9 @@ type InboundAcceptanceResult =
39
40
  sessionKey: string;
40
41
  inboundText: string;
41
42
  hasMedia: boolean;
43
+ resolvedAgentId: string;
44
+ shouldDispatch: boolean;
45
+ shouldAccumulate: boolean;
42
46
  }
43
47
  | {
44
48
  ok: false;
@@ -98,6 +102,15 @@ export type BncrInboundHandlersRuntime = {
98
102
  peer: EnsureCanonicalAgentIdPeer;
99
103
  channelId: string;
100
104
  }) => string;
105
+ defaultAdminAgentId: (args: {
106
+ cfg: BncrChannelConfigRoot;
107
+ accountId: string;
108
+ peer: EnsureCanonicalAgentIdPeer;
109
+ channelId: string;
110
+ }) => string;
111
+ defaultPublicAgentId: () => string;
112
+ sceneRegistry: Map<string, BncrSceneRecord>;
113
+ groupHistories: BncrGroupHistoryMap;
101
114
  prepareInboundAcceptance: (args: {
102
115
  parsed: ParsedInboundParams;
103
116
  canonicalAgentId: string;
@@ -116,6 +129,14 @@ export type BncrInboundHandlersRuntime = {
116
129
  cfg: BncrChannelConfigRoot;
117
130
  parsed: ParsedInboundParams;
118
131
  canonicalAgentId: string;
132
+ resolvedAgentId: string;
133
+ shouldDispatch: boolean;
134
+ shouldAccumulate: boolean;
135
+ sceneRegistry: Map<string, BncrSceneRecord>;
136
+ groupHistories: BncrGroupHistoryMap;
137
+ defaultAdminAgentId: string;
138
+ defaultPublicAgentId: string;
139
+ now: () => number;
119
140
  }) => Promise<unknown>;
120
141
  };
121
142
 
@@ -191,13 +212,37 @@ export function createBncrInboundHandlers(runtime: Omit<BncrInboundHandlersRunti
191
212
  peer,
192
213
  channelId: runtime.channelId,
193
214
  });
215
+ const defaultAdminAgentId = runtime.defaultAdminAgentId({
216
+ cfg,
217
+ accountId,
218
+ peer,
219
+ channelId: runtime.channelId,
220
+ });
194
221
  const acceptance = await runtime.prepareInboundAcceptance({ parsed, canonicalAgentId });
195
222
  if (!acceptance.ok) {
223
+ const reason = runtime
224
+ .asString((acceptance.payload as { reason?: unknown })?.reason || '')
225
+ .trim();
226
+ runtime.logInfo(
227
+ 'inbound',
228
+ `accept reject|accountId=${accountId}|msgId=${msgId ?? '-'}|scope=${runtime.formatDisplayScope(route)}|chatType=${peer.kind}|msgType=${msgType}|status=${acceptance.status}|reason=${reason || '-'}|isAdmin=${parsed.isAdmin === true}|shouldRespond=${parsed.shouldRespond === true}`,
229
+ );
196
230
  respond(acceptance.status, acceptance.payload);
197
231
  return;
198
232
  }
199
233
 
200
- const { sessionKey, inboundText, hasMedia } = acceptance;
234
+ const {
235
+ sessionKey,
236
+ inboundText,
237
+ hasMedia,
238
+ resolvedAgentId,
239
+ shouldDispatch,
240
+ shouldAccumulate,
241
+ } = acceptance;
242
+ runtime.logInfo(
243
+ 'inbound',
244
+ `accept ok|accountId=${accountId}|msgId=${msgId ?? '-'}|scope=${runtime.formatDisplayScope(route)}|chatType=${peer.kind}|msgType=${msgType}|sessionKey=${sessionKey}|agent=${resolvedAgentId}|dispatch=${shouldDispatch}|accumulate=${shouldAccumulate}|isAdmin=${parsed.isAdmin === true}|shouldRespond=${parsed.shouldRespond === true}`,
245
+ );
201
246
  runtime.logInfo(
202
247
  'inbound',
203
248
  JSON.stringify({
@@ -239,6 +284,14 @@ export function createBncrInboundHandlers(runtime: Omit<BncrInboundHandlersRunti
239
284
  cfg,
240
285
  parsed,
241
286
  canonicalAgentId,
287
+ resolvedAgentId,
288
+ shouldDispatch,
289
+ shouldAccumulate,
290
+ sceneRegistry: runtime.sceneRegistry,
291
+ groupHistories: runtime.groupHistories,
292
+ defaultAdminAgentId,
293
+ defaultPublicAgentId: runtime.defaultPublicAgentId(),
294
+ now: runtime.now,
242
295
  })
243
296
  .catch((err) => {
244
297
  runtime.logError('inbound', `process failed: ${String(err)}`, { debugOnly: true });
@@ -53,6 +53,9 @@ export function createBncrInboundSurfaceHandlersGroup(runtime: {
53
53
  clientId?: string;
54
54
  context: GatewayRequestHandlerOptions['context'];
55
55
  }) => void;
56
+ buildCanonicalSessionKey: Parameters<
57
+ typeof createBncrFileInboundHandlersComponent
58
+ >[0]['buildCanonicalSessionKey'];
56
59
  refreshLiveConnectionState: (args: {
57
60
  accountId: string;
58
61
  connId: string;
@@ -75,6 +78,14 @@ export function createBncrInboundSurfaceHandlersGroup(runtime: {
75
78
  ensureCanonicalAgentId: Parameters<
76
79
  typeof createBncrInboundHandlersComponent
77
80
  >[0]['ensureCanonicalAgentId'];
81
+ defaultAdminAgentId: Parameters<
82
+ typeof createBncrInboundHandlersComponent
83
+ >[0]['defaultAdminAgentId'];
84
+ defaultPublicAgentId: Parameters<
85
+ typeof createBncrInboundHandlersComponent
86
+ >[0]['defaultPublicAgentId'];
87
+ sceneRegistry: Parameters<typeof createBncrInboundHandlersComponent>[0]['sceneRegistry'];
88
+ groupHistories: Parameters<typeof createBncrInboundHandlersComponent>[0]['groupHistories'];
78
89
  prepareInboundAcceptance: Parameters<
79
90
  typeof createBncrInboundHandlersComponent
80
91
  >[0]['prepareInboundAcceptance'];
@@ -111,6 +122,7 @@ export function createBncrInboundSurfaceHandlersGroup(runtime: {
111
122
  matchesTransferOwner: runtime.matchesTransferOwner,
112
123
  refreshAcceptedFileTransferLiveState: runtime.refreshAcceptedFileTransferLiveState,
113
124
  logWarn: runtime.logWarn,
125
+ buildCanonicalSessionKey: runtime.buildCanonicalSessionKey,
114
126
  fileRecvTransfers: runtime.fileRecvTransfers,
115
127
  inboundFileTransferMaxBytes: runtime.inboundFileTransferMaxBytes,
116
128
  inboundFileTransferMaxChunks: runtime.inboundFileTransferMaxChunks,
@@ -136,6 +148,10 @@ export function createBncrInboundSurfaceHandlersGroup(runtime: {
136
148
  buildActiveConnectionDebugList: runtime.buildActiveConnectionDebugList,
137
149
  markLastInboundAt: runtime.markLastInboundAt,
138
150
  ensureCanonicalAgentId: runtime.ensureCanonicalAgentId,
151
+ defaultAdminAgentId: runtime.defaultAdminAgentId,
152
+ defaultPublicAgentId: runtime.defaultPublicAgentId,
153
+ sceneRegistry: runtime.sceneRegistry,
154
+ groupHistories: runtime.groupHistories,
139
155
  prepareInboundAcceptance: runtime.prepareInboundAcceptance,
140
156
  logInboundSummary: runtime.logInboundSummary,
141
157
  flushPushQueueBestEffort: runtime.flushPushQueueBestEffort,
@@ -10,6 +10,15 @@ import {
10
10
  } from '../messaging/outbound/target-resolver.ts';
11
11
  import type { BncrChannelConfigRoot } from './channel-runtime-types.ts';
12
12
 
13
+ function formatBncrHumanDisplay(route: BncrRoute): string {
14
+ const platform = asSanitizedString(route?.platform).trim();
15
+ const groupId = asSanitizedString(route?.groupId).trim();
16
+ const userId = asSanitizedString(route?.userId).trim();
17
+ if (platform && groupId && groupId !== '0') return `Bncr:${platform}:Group:${groupId}`;
18
+ if (platform && userId && userId !== '0') return `Bncr:${platform}:User:${userId}`;
19
+ return formatDisplayScope(route);
20
+ }
21
+
13
22
  type BncrMessagingRuntimeBridge = {
14
23
  canonicalAgentId?: string;
15
24
  ensureCanonicalAgentId: (params: { cfg: BncrChannelConfigRoot; accountId: string }) => string;
@@ -69,16 +78,24 @@ export function normalizeBncrMessagingTarget(raw: string) {
69
78
  }
70
79
 
71
80
  export function formatBncrMessagingTargetDisplay({ target }: BncrMessagingTargetDisplayInput) {
72
- if (typeof target === 'string') return asSanitizedString(target).trim();
81
+ if (typeof target === 'string') {
82
+ const parsed = parseExplicitTarget(asSanitizedString(target).trim());
83
+ return parsed?.route ? formatBncrHumanDisplay(parsed.route) : asSanitizedString(target).trim();
84
+ }
73
85
  if (!isDisplayTarget(target)) return '';
74
86
  const displayScope = asSanitizedString(target?.displayScope || target?.to).trim();
75
- if (displayScope) return displayScope;
87
+ if (displayScope) {
88
+ const parsed = parseExplicitTarget(displayScope);
89
+ if (parsed?.route) return formatBncrHumanDisplay(parsed.route);
90
+ return displayScope;
91
+ }
76
92
  if (target.platform || target.groupId || target.userId) {
77
- return formatDisplayScope({
93
+ const route = {
78
94
  platform: asSanitizedString(target.platform).trim(),
79
95
  groupId: asSanitizedString(target.groupId).trim(),
80
96
  userId: asSanitizedString(target.userId).trim(),
81
- });
97
+ };
98
+ return formatBncrHumanDisplay(route);
82
99
  }
83
100
  return '';
84
101
  }
@@ -188,7 +205,7 @@ export function createBncrMessagingTargetResolver(getBridge: () => BncrMessaging
188
205
  return {
189
206
  to: resolved.displayScope,
190
207
  kind: resolved.kind,
191
- display: resolved.displayScope,
208
+ display: formatBncrHumanDisplay(resolved.route),
192
209
  source: 'normalized' as const,
193
210
  };
194
211
  },
@@ -0,0 +1,155 @@
1
+ import type {
2
+ BncrGroupReplyMode,
3
+ BncrSceneKind,
4
+ BncrSceneRecord,
5
+ BncrSceneStatus,
6
+ } from './channel-runtime-types.ts';
7
+
8
+ type ParsedInbound = ReturnType<
9
+ typeof import('../messaging/inbound/parse.ts')['parseBncrInboundParams']
10
+ >;
11
+
12
+ export type BncrSceneRegistryDecision =
13
+ | {
14
+ allowed: true;
15
+ scene: BncrSceneRecord;
16
+ agentId: string;
17
+ }
18
+ | {
19
+ allowed: false;
20
+ scene: BncrSceneRecord;
21
+ reason: string;
22
+ replyPolicy: 'silent' | 'pending';
23
+ };
24
+
25
+ function asSceneKind(parsed: ParsedInbound): BncrSceneKind {
26
+ return parsed.peer.kind === 'group' ? 'group' : 'direct';
27
+ }
28
+
29
+ function defaultGroupReplyMode(kind: BncrSceneKind): BncrGroupReplyMode | undefined {
30
+ return kind === 'group' ? 'admin' : undefined;
31
+ }
32
+
33
+ export function buildSceneKey(parsed: ParsedInbound): string {
34
+ return parsed.peer.kind === 'group'
35
+ ? `${parsed.platform}:${parsed.groupId}`
36
+ : `${parsed.platform}:${parsed.userId}`;
37
+ }
38
+
39
+ export function buildSceneRecord(args: {
40
+ parsed: ParsedInbound;
41
+ sceneKey: string;
42
+ kind: BncrSceneKind;
43
+ status: BncrSceneStatus;
44
+ agentId?: string;
45
+ lastSeenAt: number;
46
+ }): BncrSceneRecord {
47
+ const { parsed, sceneKey, kind, status, agentId, lastSeenAt } = args;
48
+ return {
49
+ sceneKey,
50
+ kind,
51
+ status,
52
+ platform: parsed.platform,
53
+ ...(parsed.userId ? { userId: parsed.userId } : {}),
54
+ ...(parsed.userName ? { userName: parsed.userName } : {}),
55
+ ...(kind === 'group' && parsed.groupId ? { groupId: parsed.groupId } : {}),
56
+ ...(kind === 'group' && parsed.groupName ? { groupName: parsed.groupName } : {}),
57
+ ...(agentId ? { agentId } : {}),
58
+ ...(kind === 'group' ? { groupReplyMode: defaultGroupReplyMode(kind) } : {}),
59
+ lastSeenAt,
60
+ };
61
+ }
62
+
63
+ export function decideSceneAdmission(args: {
64
+ parsed: ParsedInbound;
65
+ now: number;
66
+ sceneRegistry: Map<string, BncrSceneRecord>;
67
+ defaultAdminAgentId: string;
68
+ defaultPublicAgentId: string;
69
+ }): BncrSceneRegistryDecision {
70
+ const { parsed, now, sceneRegistry, defaultAdminAgentId, defaultPublicAgentId } = args;
71
+ const sceneKey = buildSceneKey(parsed);
72
+ const kind = asSceneKind(parsed);
73
+ const existing = sceneRegistry.get(sceneKey);
74
+
75
+ if (existing) {
76
+ const nextScene = {
77
+ ...existing,
78
+ ...(parsed.userId ? { userId: parsed.userId } : {}),
79
+ ...(parsed.userName ? { userName: parsed.userName } : {}),
80
+ ...(kind === 'group' && parsed.groupId ? { groupId: parsed.groupId } : {}),
81
+ ...(kind === 'group' && parsed.groupName ? { groupName: parsed.groupName } : {}),
82
+ ...(kind === 'group' && !existing.groupReplyMode
83
+ ? { groupReplyMode: defaultGroupReplyMode(kind) }
84
+ : {}),
85
+ lastSeenAt: now,
86
+ } satisfies BncrSceneRecord;
87
+
88
+ if (kind === 'group' && nextScene.status === 'denied' && parsed.isAdmin) {
89
+ const allowedScene = {
90
+ ...nextScene,
91
+ status: 'allowed',
92
+ } satisfies BncrSceneRecord;
93
+ sceneRegistry.set(sceneKey, allowedScene);
94
+ return {
95
+ allowed: true,
96
+ scene: allowedScene,
97
+ agentId: allowedScene.agentId || defaultPublicAgentId,
98
+ };
99
+ }
100
+
101
+ sceneRegistry.set(sceneKey, nextScene);
102
+
103
+ if (nextScene.status === 'allowed') {
104
+ const fallbackAgentId =
105
+ kind === 'group'
106
+ ? defaultPublicAgentId
107
+ : parsed.isAdmin
108
+ ? defaultAdminAgentId
109
+ : defaultPublicAgentId;
110
+ return {
111
+ allowed: true,
112
+ scene: nextScene,
113
+ agentId: nextScene.agentId || fallbackAgentId,
114
+ };
115
+ }
116
+
117
+ return {
118
+ allowed: false,
119
+ scene: nextScene,
120
+ reason: nextScene.status === 'pending' ? 'scene pending approval' : 'scene denied',
121
+ replyPolicy:
122
+ kind === 'group' ? 'silent' : nextScene.status === 'pending' ? 'pending' : 'silent',
123
+ };
124
+ }
125
+
126
+ if (parsed.isAdmin) {
127
+ const agentId = kind === 'group' ? defaultPublicAgentId : defaultAdminAgentId;
128
+ const scene = buildSceneRecord({
129
+ parsed,
130
+ sceneKey,
131
+ kind,
132
+ status: 'allowed',
133
+ agentId,
134
+ lastSeenAt: now,
135
+ });
136
+ sceneRegistry.set(sceneKey, scene);
137
+ return { allowed: true, scene, agentId };
138
+ }
139
+
140
+ const scene = buildSceneRecord({
141
+ parsed,
142
+ sceneKey,
143
+ kind,
144
+ status: kind === 'direct' ? 'pending' : 'denied',
145
+ lastSeenAt: now,
146
+ });
147
+ sceneRegistry.set(sceneKey, scene);
148
+
149
+ return {
150
+ allowed: false,
151
+ scene,
152
+ reason: kind === 'direct' ? 'scene pending approval' : 'scene denied',
153
+ replyPolicy: kind === 'direct' ? 'pending' : 'silent',
154
+ };
155
+ }
@@ -8,16 +8,25 @@ import {
8
8
  writeOpenClawJsonFileAtomically,
9
9
  } from '../openclaw/sdk-helpers.ts';
10
10
  import type {
11
+ BncrGroupReplyMode,
11
12
  BncrPersistedAccountTimestamp,
13
+ BncrPersistedGroupHistoryBucket,
14
+ BncrPersistedGroupHistoryEntry,
15
+ BncrPersistedGroupHistoryMediaEntry,
12
16
  BncrPersistedLastSession,
13
17
  BncrPersistedSessionRoute,
14
18
  PersistedState as BncrPersistedState,
19
+ BncrSceneRecord,
15
20
  } from './channel-runtime-types.ts';
16
21
 
22
+ const GROUP_REPLY_MODES = new Set<BncrGroupReplyMode>(['admin', 'mention', 'hybrid', 'all']);
23
+
17
24
  type BncrPersistedStateStoreInput = {
18
25
  outbox?: unknown;
19
26
  deadLetter?: unknown;
20
27
  sessionRoutes?: unknown;
28
+ sceneRegistry?: unknown;
29
+ groupHistories?: unknown;
21
30
  lastSessionByAccount?: unknown;
22
31
  lastActivityByAccount?: unknown;
23
32
  lastInboundByAccount?: unknown;
@@ -28,6 +37,9 @@ type BncrPersistedStateStoreInput = {
28
37
  type PersistedAccountTimestampInput = Partial<BncrPersistedAccountTimestamp>;
29
38
  type PersistedLastSessionInput = Partial<BncrPersistedLastSession>;
30
39
  type PersistedSessionRouteInput = Partial<BncrPersistedSessionRoute>;
40
+ type PersistedSceneRecordInput = Partial<BncrSceneRecord>;
41
+ type PersistedGroupHistoryBucketInput = Partial<BncrPersistedGroupHistoryBucket>;
42
+ type PersistedGroupHistoryEntryInput = Partial<BncrPersistedGroupHistoryEntry>;
31
43
 
32
44
  export function createBncrStateStore(runtime: {
33
45
  getStatePath: () => string | null;
@@ -50,6 +62,8 @@ export function createBncrStateStore(runtime: {
50
62
  maxDeadLetterEntries: number;
51
63
  maxSessionRouteEntries: number;
52
64
  maxAccountActivityEntries: number;
65
+ sceneRegistry: Map<string, BncrSceneRecord>;
66
+ groupHistories: Map<string, BncrPersistedGroupHistoryEntry[]>;
53
67
  outbox: Map<string, OutboxEntry>;
54
68
  getDeadLetter: () => OutboxEntry[];
55
69
  setDeadLetter: (entries: OutboxEntry[]) => void;
@@ -62,6 +76,117 @@ export function createBncrStateStore(runtime: {
62
76
  getLastDriftSnapshot: () => BncrPersistedState['lastDriftSnapshot'];
63
77
  setLastDriftSnapshot: (value: BncrPersistedState['lastDriftSnapshot']) => void;
64
78
  }) {
79
+ function loadPersistedSceneRegistry(persisted: unknown): void {
80
+ runtime.sceneRegistry.clear();
81
+ const items = Array.isArray(persisted) ? (persisted as PersistedSceneRecordInput[]) : [];
82
+ for (const item of items) {
83
+ if (!item || typeof item !== 'object') continue;
84
+ const sceneKey = runtime.asString(item.sceneKey || '').trim();
85
+ const kind = runtime.asString(item.kind || '').trim();
86
+ const status = runtime.asString(item.status || '').trim();
87
+ const platform = runtime.asString(item.platform || '').trim();
88
+ const lastSeenAt = runtime.finiteNumberOr(item.lastSeenAt, 0);
89
+ if (!sceneKey || !platform || lastSeenAt <= 0) continue;
90
+ if (kind !== 'direct' && kind !== 'group') continue;
91
+ if (status !== 'pending' && status !== 'allowed' && status !== 'denied') continue;
92
+
93
+ runtime.sceneRegistry.set(sceneKey, {
94
+ sceneKey,
95
+ kind,
96
+ status,
97
+ platform,
98
+ ...(runtime.asString(item.userId || '').trim()
99
+ ? { userId: runtime.asString(item.userId || '').trim() }
100
+ : {}),
101
+ ...(runtime.asString(item.userName || '').trim()
102
+ ? { userName: runtime.asString(item.userName || '').trim() }
103
+ : {}),
104
+ ...(runtime.asString(item.groupId || '').trim()
105
+ ? { groupId: runtime.asString(item.groupId || '').trim() }
106
+ : {}),
107
+ ...(runtime.asString(item.groupName || '').trim()
108
+ ? { groupName: runtime.asString(item.groupName || '').trim() }
109
+ : {}),
110
+ ...(runtime.asString(item.agentId || '').trim()
111
+ ? { agentId: runtime.asString(item.agentId || '').trim() }
112
+ : {}),
113
+ ...(kind === 'group' &&
114
+ GROUP_REPLY_MODES.has(
115
+ runtime.asString(item.groupReplyMode || '').trim() as BncrGroupReplyMode,
116
+ )
117
+ ? {
118
+ groupReplyMode: runtime
119
+ .asString(item.groupReplyMode || '')
120
+ .trim() as BncrGroupReplyMode,
121
+ }
122
+ : {}),
123
+ lastSeenAt,
124
+ });
125
+ }
126
+ }
127
+
128
+ function dumpPersistedSceneRegistry() {
129
+ return Array.from(runtime.sceneRegistry.values()).sort((a, b) => a.lastSeenAt - b.lastSeenAt);
130
+ }
131
+
132
+ function loadPersistedGroupHistories(persisted: unknown): void {
133
+ runtime.groupHistories.clear();
134
+ const buckets = Array.isArray(persisted)
135
+ ? (persisted as PersistedGroupHistoryBucketInput[])
136
+ : [];
137
+ for (const bucket of buckets) {
138
+ const key = runtime.asString(bucket?.key || '').trim();
139
+ if (!key) continue;
140
+ const entries: BncrPersistedGroupHistoryEntry[] = [];
141
+ const rawEntries = Array.isArray(bucket?.entries)
142
+ ? (bucket.entries as PersistedGroupHistoryEntryInput[])
143
+ : [];
144
+ for (const entry of rawEntries) {
145
+ const sender = runtime.asString(entry?.sender || '').trim();
146
+ const body = runtime.asString(entry?.body || '').trim();
147
+ if (!sender || !body) continue;
148
+ const timestamp = runtime.finiteNumberOr(entry?.timestamp, 0);
149
+ const messageId = runtime.asString(entry?.messageId || '').trim();
150
+
151
+ const media: BncrPersistedGroupHistoryMediaEntry[] = [];
152
+ const rawMedia = Array.isArray(entry?.media)
153
+ ? (entry.media as Partial<BncrPersistedGroupHistoryMediaEntry>[])
154
+ : [];
155
+ for (const item of rawMedia) {
156
+ const path = runtime.asString(item?.path || '').trim();
157
+ if (!path) continue;
158
+ const contentType = runtime.asString(item?.contentType || '').trim();
159
+ const kind = runtime.asString(item?.kind || '').trim();
160
+ const mediaMessageId = runtime.asString(item?.messageId || '').trim();
161
+ const normalizedMedia: BncrPersistedGroupHistoryMediaEntry = {
162
+ path,
163
+ ...(contentType ? { contentType } : {}),
164
+ ...(kind ? { kind: kind as BncrPersistedGroupHistoryMediaEntry['kind'] } : {}),
165
+ ...(mediaMessageId ? { messageId: mediaMessageId } : {}),
166
+ };
167
+ media.push(normalizedMedia);
168
+ }
169
+
170
+ const normalizedEntry: BncrPersistedGroupHistoryEntry = {
171
+ sender,
172
+ body,
173
+ ...(timestamp > 0 ? { timestamp } : {}),
174
+ ...(messageId ? { messageId } : {}),
175
+ ...(media.length > 0 ? { media } : {}),
176
+ };
177
+ entries.push(normalizedEntry);
178
+ }
179
+ if (entries.length > 0) runtime.groupHistories.set(key, entries.slice(-50));
180
+ }
181
+ }
182
+
183
+ function dumpPersistedGroupHistories() {
184
+ return Array.from(runtime.groupHistories.entries()).map(([key, entries]) => ({
185
+ key,
186
+ entries: entries.slice(-50),
187
+ }));
188
+ }
189
+
65
190
  function loadPersistedAccountTimestampMap(target: Map<string, number>, persisted: unknown): void {
66
191
  target.clear();
67
192
  const items = Array.isArray(persisted)
@@ -208,6 +333,8 @@ export function createBncrStateStore(runtime: {
208
333
  runtime.setDeadLetter(deadLetter);
209
334
 
210
335
  loadPersistedSessionRoutes(data.sessionRoutes);
336
+ loadPersistedSceneRegistry(data.sceneRegistry);
337
+ loadPersistedGroupHistories(data.groupHistories);
211
338
  loadPersistedLastSessionMap(data.lastSessionByAccount);
212
339
  loadPersistedAccountTimestampMap(runtime.lastActivityByAccount, data.lastActivityByAccount);
213
340
  loadPersistedAccountTimestampMap(runtime.lastInboundByAccount, data.lastInboundByAccount);
@@ -225,6 +352,8 @@ export function createBncrStateStore(runtime: {
225
352
  outbox: Array.from(runtime.outbox.values()),
226
353
  deadLetter: runtime.getDeadLetter().slice(-runtime.maxDeadLetterEntries),
227
354
  sessionRoutes: dumpPersistedSessionRoutes(),
355
+ sceneRegistry: dumpPersistedSceneRegistry(),
356
+ groupHistories: dumpPersistedGroupHistories(),
228
357
  lastSessionByAccount: dumpPersistedLastSessionMap(),
229
358
  lastActivityByAccount: dumpPersistedAccountTimestampMap(runtime.lastActivityByAccount),
230
359
  lastInboundByAccount: dumpPersistedAccountTimestampMap(runtime.lastInboundByAccount),
@@ -242,6 +371,10 @@ export function createBncrStateStore(runtime: {
242
371
  dumpPersistedLastSessionMap,
243
372
  loadPersistedSessionRoutes,
244
373
  dumpPersistedSessionRoutes,
374
+ loadPersistedSceneRegistry,
375
+ dumpPersistedSceneRegistry,
376
+ loadPersistedGroupHistories,
377
+ dumpPersistedGroupHistories,
245
378
  backfillAccountActivityFromSessionRoutes,
246
379
  loadState,
247
380
  flushState,