@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
@@ -0,0 +1,171 @@
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
+ const shouldForceDirectNonAdminPublic = (agentId?: string) =>
75
+ kind === 'direct' && parsed.isAdmin !== true && (!agentId || agentId === defaultAdminAgentId);
76
+
77
+ if (existing) {
78
+ const forcedAgentId = shouldForceDirectNonAdminPublic(existing.agentId)
79
+ ? defaultPublicAgentId
80
+ : existing.agentId;
81
+ const nextScene = {
82
+ ...existing,
83
+ ...(parsed.userId ? { userId: parsed.userId } : {}),
84
+ ...(parsed.userName ? { userName: parsed.userName } : {}),
85
+ ...(kind === 'group' && parsed.groupId ? { groupId: parsed.groupId } : {}),
86
+ ...(kind === 'group' && parsed.groupName ? { groupName: parsed.groupName } : {}),
87
+ ...(forcedAgentId ? { agentId: forcedAgentId } : {}),
88
+ ...(kind === 'group' && !existing.groupReplyMode
89
+ ? { groupReplyMode: defaultGroupReplyMode(kind) }
90
+ : {}),
91
+ lastSeenAt: now,
92
+ } satisfies BncrSceneRecord;
93
+
94
+ if (kind === 'group' && nextScene.status === 'denied' && parsed.isAdmin) {
95
+ const allowedScene = {
96
+ ...nextScene,
97
+ status: 'allowed',
98
+ } satisfies BncrSceneRecord;
99
+ sceneRegistry.set(sceneKey, allowedScene);
100
+ return {
101
+ allowed: true,
102
+ scene: allowedScene,
103
+ agentId: allowedScene.agentId || defaultPublicAgentId,
104
+ };
105
+ }
106
+
107
+ sceneRegistry.set(sceneKey, nextScene);
108
+
109
+ if (nextScene.status === 'allowed') {
110
+ const fallbackAgentId =
111
+ kind === 'group'
112
+ ? defaultPublicAgentId
113
+ : parsed.isAdmin
114
+ ? defaultAdminAgentId
115
+ : defaultPublicAgentId;
116
+ return {
117
+ allowed: true,
118
+ scene: nextScene,
119
+ agentId: nextScene.agentId || fallbackAgentId,
120
+ };
121
+ }
122
+
123
+ return {
124
+ allowed: false,
125
+ scene: nextScene,
126
+ reason: nextScene.status === 'pending' ? 'scene pending approval' : 'scene denied',
127
+ replyPolicy:
128
+ kind === 'group' ? 'silent' : nextScene.status === 'pending' ? 'pending' : 'silent',
129
+ };
130
+ }
131
+
132
+ if (parsed.isAdmin) {
133
+ const agentId = kind === 'group' ? defaultPublicAgentId : defaultAdminAgentId;
134
+ const scene = buildSceneRecord({
135
+ parsed,
136
+ sceneKey,
137
+ kind,
138
+ status: 'allowed',
139
+ agentId,
140
+ lastSeenAt: now,
141
+ });
142
+ sceneRegistry.set(sceneKey, scene);
143
+ return { allowed: true, scene, agentId };
144
+ }
145
+
146
+ const directNonAdminAllowed = kind === 'direct';
147
+ const scene = buildSceneRecord({
148
+ parsed,
149
+ sceneKey,
150
+ kind,
151
+ status: directNonAdminAllowed ? 'allowed' : 'denied',
152
+ agentId: directNonAdminAllowed ? defaultPublicAgentId : undefined,
153
+ lastSeenAt: now,
154
+ });
155
+ sceneRegistry.set(sceneKey, scene);
156
+
157
+ if (directNonAdminAllowed) {
158
+ return {
159
+ allowed: true,
160
+ scene,
161
+ agentId: scene.agentId || defaultPublicAgentId,
162
+ };
163
+ }
164
+
165
+ return {
166
+ allowed: false,
167
+ scene,
168
+ reason: 'scene denied',
169
+ replyPolicy: 'silent',
170
+ };
171
+ }
@@ -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,
@@ -6,6 +6,7 @@ import type {
6
6
  FileSendTransferState,
7
7
  OutboxEntry,
8
8
  } from '../core/types.ts';
9
+ import type { BncrPersistedGroupHistoryEntry, BncrSceneRecord } from './channel-runtime-types.ts';
9
10
  import { createBncrStateStore } from './state-store.ts';
10
11
  import { createBncrTransientStateRuntime } from './transient-state-runtime.ts';
11
12
 
@@ -31,6 +32,8 @@ export function createBncrStateTransientRuntimeGroup(runtime: {
31
32
  maxDeadLetterEntries: number;
32
33
  maxSessionRouteEntries: number;
33
34
  maxAccountActivityEntries: number;
35
+ sceneRegistry: Map<string, BncrSceneRecord>;
36
+ groupHistories: Map<string, BncrPersistedGroupHistoryEntry[]>;
34
37
  outbox: Map<string, OutboxEntry>;
35
38
  getDeadLetter: () => OutboxEntry[];
36
39
  setDeadLetter: (entries: OutboxEntry[]) => void;
@@ -69,6 +72,8 @@ export function createBncrStateTransientRuntimeGroup(runtime: {
69
72
  maxDeadLetterEntries: runtime.maxDeadLetterEntries,
70
73
  maxSessionRouteEntries: runtime.maxSessionRouteEntries,
71
74
  maxAccountActivityEntries: runtime.maxAccountActivityEntries,
75
+ sceneRegistry: runtime.sceneRegistry,
76
+ groupHistories: runtime.groupHistories,
72
77
  outbox: runtime.outbox,
73
78
  getDeadLetter: runtime.getDeadLetter,
74
79
  setDeadLetter: runtime.setDeadLetter,
@@ -95,11 +95,11 @@ export function createBncrTargetRuntime(runtime: {
95
95
  if (!route) {
96
96
  runtime.logWarn(
97
97
  'target',
98
- `invalid raw=${raw} accountId=${acc} reason=unparseable-or-unknown standardTo=Bncr:<platform>:<groupId>:<userId>|Bncr:<platform>:<userId> standardSessionKey=agent:<agentId>:bncr:direct:<hex(scope)>`,
98
+ `invalid raw=${raw} accountId=${acc} reason=unparseable-or-unknown canonicalTo=Bncr:<platform>:0:<userId>|Bncr:<platform>:<groupId>:0 acceptedAliases=Bncr:<platform>:user:<userId>|Bncr:<platform>:group:<groupId> standardSessionKey=agent:<agentId>:bncr:<direct|group>:<hex(scope)>`,
99
99
  { debugOnly: true },
100
100
  );
101
101
  throw new Error(
102
- `bncr invalid target(standard: Bncr:<platform>:<groupId>:<userId> | Bncr:<platform>:<userId>): ${raw}`,
102
+ `bncr invalid target(canonical: Bncr:<platform>:0:<userId> | Bncr:<platform>:<groupId>:0; aliases: Bncr:<platform>:user:<userId> | Bncr:<platform>:group:<groupId>): ${raw}`,
103
103
  );
104
104
  }
105
105