@xmoxmo/bncr 0.4.6 → 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 (34) hide show
  1. package/package.json +1 -1
  2. package/src/channel.ts +41 -2
  3. package/src/core/targets.ts +106 -17
  4. package/src/messaging/inbound/commands.ts +263 -51
  5. package/src/messaging/inbound/context-facts.ts +126 -14
  6. package/src/messaging/inbound/contracts.ts +24 -0
  7. package/src/messaging/inbound/dispatch-prep.ts +214 -39
  8. package/src/messaging/inbound/dispatch.ts +71 -5
  9. package/src/messaging/inbound/gate.ts +56 -86
  10. package/src/messaging/inbound/group-history.ts +189 -0
  11. package/src/messaging/inbound/native-command-runtime.ts +77 -61
  12. package/src/messaging/inbound/native-command.ts +92 -8
  13. package/src/messaging/inbound/parse.ts +113 -8
  14. package/src/messaging/inbound/reply-dispatch-serial.ts +62 -0
  15. package/src/messaging/inbound/reply-dispatch.ts +252 -77
  16. package/src/messaging/inbound/scene-admin.ts +269 -0
  17. package/src/messaging/inbound/session-label.ts +122 -13
  18. package/src/messaging/inbound/session-meta-task.ts +17 -0
  19. package/src/messaging/inbound/turn-context.ts +184 -71
  20. package/src/openclaw/channel-runtime-contracts.ts +1 -0
  21. package/src/plugin/channel-components.ts +34 -1
  22. package/src/plugin/channel-inbound-helpers.ts +9 -2
  23. package/src/plugin/channel-runtime-builders-delivery.ts +24 -1
  24. package/src/plugin/channel-runtime-types.ts +42 -0
  25. package/src/plugin/file-inbound-init.ts +27 -12
  26. package/src/plugin/file-inbound-runtime.ts +2 -0
  27. package/src/plugin/inbound-acceptance.ts +82 -1
  28. package/src/plugin/inbound-handlers.ts +55 -2
  29. package/src/plugin/inbound-surface-handlers-group.ts +16 -0
  30. package/src/plugin/messaging.ts +22 -5
  31. package/src/plugin/scene-registry.ts +155 -0
  32. package/src/plugin/state-store.ts +133 -0
  33. package/src/plugin/state-transient-runtime-group.ts +5 -0
  34. package/src/plugin/target-runtime.ts +2 -2
@@ -0,0 +1,269 @@
1
+ import type {
2
+ BncrGroupReplyMode,
3
+ BncrSceneRecord,
4
+ BncrSceneStatus,
5
+ } from '../../plugin/channel-runtime-types.ts';
6
+ import type { ParsedInbound } from './dispatch-prep.ts';
7
+ import type { NativeCommand } from './native-command.ts';
8
+
9
+ export type BncrSceneAdminCommand =
10
+ | { kind: 'allow'; sceneKey?: string }
11
+ | { kind: 'deny'; sceneKey?: string }
12
+ | { kind: 'revoke'; sceneKey?: string }
13
+ | { kind: 'bind'; sceneKey?: string; agentId: string }
14
+ | { kind: 'mode-help' }
15
+ | { kind: 'mode-get'; sceneKey?: string }
16
+ | { kind: 'mode'; sceneKey: string; mode: BncrGroupReplyMode }
17
+ | { kind: 'list'; scope: 'pending' | 'scenes' };
18
+
19
+ const GROUP_REPLY_MODES = new Set<BncrGroupReplyMode>(['admin', 'mention', 'hybrid', 'all']);
20
+
21
+ const MODE_HELP_TEXT = [
22
+ '💬 Group reply modes',
23
+ ' • default: admin',
24
+ ' • admin: 仅管理员|消息上送并逐条回复',
25
+ ' • mention: 全员|消息上送 仅指定消息触发回复',
26
+ ' • hybrid: 全员|消息上送 管理员逐条回复 其他人仅指定消息触发回复',
27
+ ' • all: 全员|消息上送并逐条回复',
28
+ '',
29
+ 'Specified messages include:',
30
+ ' • @bot',
31
+ ' • reply to bot',
32
+ ' • platform-marked should-respond messages',
33
+ '',
34
+ 'Usage:',
35
+ ' • /bncr mode',
36
+ ' • /bncr mode help',
37
+ ' • /bncr mode <admin|mention|hybrid|all> [<platform>:<groupId>]',
38
+ ].join('\n');
39
+
40
+ export type ParsedSceneAdminCommand =
41
+ | { matched: false }
42
+ | { matched: true; valid: false; text: string }
43
+ | { matched: true; valid: true; command: BncrSceneAdminCommand };
44
+
45
+ function normalizeToken(value: string): string {
46
+ return String(value || '').trim();
47
+ }
48
+
49
+ function splitArgs(raw: string): string[] {
50
+ return normalizeToken(raw)
51
+ .split(/\s+/)
52
+ .map((part) => part.trim())
53
+ .filter(Boolean);
54
+ }
55
+
56
+ function resolveCurrentGroupSceneKey(parsed: ParsedInbound): string | null {
57
+ if (parsed.peer.kind !== 'group') return null;
58
+ const platform = normalizeToken(parsed.platform);
59
+ const groupId = normalizeToken(parsed.groupId);
60
+ if (!platform || !groupId || groupId === '0') return null;
61
+ return `${platform}:${groupId}`;
62
+ }
63
+
64
+ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdminCommand {
65
+ const args = splitArgs(command.argsText);
66
+ switch (command.command) {
67
+ case 'allow':
68
+ return args.length <= 1
69
+ ? { matched: true, valid: true, command: { kind: 'allow', sceneKey: args[0] } }
70
+ : { matched: true, valid: false, text: 'Usage: /bncr allow [<sceneKey>]' };
71
+ case 'deny':
72
+ return args.length <= 1
73
+ ? { matched: true, valid: true, command: { kind: 'deny', sceneKey: args[0] } }
74
+ : { matched: true, valid: false, text: 'Usage: /bncr deny [<sceneKey>]' };
75
+ case 'revoke':
76
+ return args.length <= 1
77
+ ? { matched: true, valid: true, command: { kind: 'revoke', sceneKey: args[0] } }
78
+ : { matched: true, valid: false, text: 'Usage: /bncr revoke [<sceneKey>]' };
79
+ case 'bind':
80
+ if (args.length === 1) {
81
+ return {
82
+ matched: true,
83
+ valid: true,
84
+ command: { kind: 'bind', agentId: args[0] },
85
+ };
86
+ }
87
+ if (args.length === 2) {
88
+ return {
89
+ matched: true,
90
+ valid: true,
91
+ command: { kind: 'bind', agentId: args[0], sceneKey: args[1] },
92
+ };
93
+ }
94
+ return {
95
+ matched: true,
96
+ valid: false,
97
+ text: 'Usage: /bncr bind <agentId> [<sceneKey>]',
98
+ };
99
+ case 'mode':
100
+ if (args.length === 0) {
101
+ return { matched: true, valid: true, command: { kind: 'mode-get' } };
102
+ }
103
+ if (args.length === 1) {
104
+ if (args[0] === 'help') {
105
+ return {
106
+ matched: true,
107
+ valid: true,
108
+ command: { kind: 'mode-help' },
109
+ };
110
+ }
111
+ if (GROUP_REPLY_MODES.has(args[0] as BncrGroupReplyMode)) {
112
+ return {
113
+ matched: true,
114
+ valid: true,
115
+ command: { kind: 'mode', sceneKey: '', mode: args[0] as BncrGroupReplyMode },
116
+ };
117
+ }
118
+ return {
119
+ matched: true,
120
+ valid: true,
121
+ command: { kind: 'mode-get', sceneKey: args[0] },
122
+ };
123
+ }
124
+ if (GROUP_REPLY_MODES.has(args[0] as BncrGroupReplyMode) && args[1]) {
125
+ return {
126
+ matched: true,
127
+ valid: true,
128
+ command: { kind: 'mode', sceneKey: args[1], mode: args[0] as BncrGroupReplyMode },
129
+ };
130
+ }
131
+ return {
132
+ matched: true,
133
+ valid: false,
134
+ text: 'Usage: /bncr mode | /bncr mode <admin|mention|hybrid|all> [<sceneKey>]',
135
+ };
136
+ case 'list':
137
+ if (args[0] === 'pending') {
138
+ return { matched: true, valid: true, command: { kind: 'list', scope: 'pending' } };
139
+ }
140
+ if (args[0] === 'scenes') {
141
+ return { matched: true, valid: true, command: { kind: 'list', scope: 'scenes' } };
142
+ }
143
+ return { matched: true, valid: false, text: 'Usage: /bncr list <pending|scenes>' };
144
+ default:
145
+ return { matched: false };
146
+ }
147
+ }
148
+
149
+ function formatSceneLine(scene: BncrSceneRecord): string {
150
+ const idPart =
151
+ scene.kind === 'group' ? scene.groupId || scene.sceneKey : scene.userId || scene.sceneKey;
152
+ const namePart = scene.kind === 'group' ? scene.groupName || '' : scene.userName || '';
153
+ const agentPart = scene.agentId ? ` agent=${scene.agentId}` : '';
154
+ const modePart =
155
+ scene.kind === 'group' && scene.groupReplyMode ? ` mode=${scene.groupReplyMode}` : '';
156
+ const labelPart = namePart ? ` name=${namePart}` : '';
157
+ return `${scene.sceneKey} status=${scene.status} kind=${scene.kind} id=${idPart}${labelPart}${agentPart}${modePart}`;
158
+ }
159
+
160
+ function applySceneStatus(scene: BncrSceneRecord, status: BncrSceneStatus): BncrSceneRecord {
161
+ return {
162
+ ...scene,
163
+ status,
164
+ };
165
+ }
166
+
167
+ export function executeSceneAdminCommand(args: {
168
+ parsed: ParsedInbound;
169
+ command: BncrSceneAdminCommand;
170
+ sceneRegistry: Map<string, BncrSceneRecord>;
171
+ defaultAdminAgentId: string;
172
+ defaultPublicAgentId: string;
173
+ now: () => number;
174
+ }): { ok: true; text: string } | { ok: false; text: string } {
175
+ const { parsed, command, sceneRegistry, defaultAdminAgentId, defaultPublicAgentId, now } = args;
176
+
177
+ if (!parsed.isAdmin) {
178
+ return { ok: false, text: 'Admin permission required.' };
179
+ }
180
+
181
+ if (command.kind === 'list') {
182
+ const scenes = Array.from(sceneRegistry.values())
183
+ .filter((scene) => (command.scope === 'pending' ? scene.status === 'pending' : true))
184
+ .sort((a, b) => a.lastSeenAt - b.lastSeenAt);
185
+ if (scenes.length === 0) {
186
+ return {
187
+ ok: true,
188
+ text: command.scope === 'pending' ? 'No pending scenes.' : 'No scenes recorded.',
189
+ };
190
+ }
191
+ return { ok: true, text: scenes.map(formatSceneLine).join('\n') };
192
+ }
193
+
194
+ if (command.kind === 'mode-help') {
195
+ return { ok: true, text: MODE_HELP_TEXT };
196
+ }
197
+
198
+ if (command.kind === 'mode-get') {
199
+ const sceneKey = command.sceneKey || resolveCurrentGroupSceneKey(parsed);
200
+ if (!sceneKey) {
201
+ return { ok: false, text: 'Current group mode query only works inside a group chat.' };
202
+ }
203
+ const existingScene = sceneRegistry.get(sceneKey);
204
+ if (!existingScene) {
205
+ return { ok: false, text: `Scene not found: ${sceneKey}` };
206
+ }
207
+ if (existingScene.kind !== 'group') {
208
+ return { ok: false, text: `Scene ${sceneKey} is not a group scene.` };
209
+ }
210
+ return {
211
+ ok: true,
212
+ text: `Current ${sceneKey} reply mode is ${existingScene.groupReplyMode || 'admin'}.`,
213
+ };
214
+ }
215
+
216
+ const sceneKey = command.sceneKey || resolveCurrentGroupSceneKey(parsed);
217
+ if (!sceneKey) {
218
+ return { ok: false, text: 'Current group shortcut only works inside a group chat.' };
219
+ }
220
+
221
+ const existing = sceneRegistry.get(sceneKey);
222
+ if (!existing) {
223
+ return { ok: false, text: `Scene not found: ${sceneKey}` };
224
+ }
225
+
226
+ if (command.kind === 'revoke') {
227
+ sceneRegistry.delete(sceneKey);
228
+ return { ok: true, text: `Revoked scene ${sceneKey}.` };
229
+ }
230
+
231
+ if (command.kind === 'bind') {
232
+ sceneRegistry.set(sceneKey, {
233
+ ...existing,
234
+ agentId: command.agentId,
235
+ lastSeenAt: now(),
236
+ });
237
+ return { ok: true, text: `Bound ${sceneKey} to agent ${command.agentId}.` };
238
+ }
239
+
240
+ if (command.kind === 'mode') {
241
+ if (existing.kind !== 'group') {
242
+ return { ok: false, text: `Scene ${sceneKey} is not a group scene.` };
243
+ }
244
+ sceneRegistry.set(sceneKey, {
245
+ ...existing,
246
+ groupReplyMode: command.mode,
247
+ lastSeenAt: now(),
248
+ });
249
+ return { ok: true, text: `Set ${sceneKey} reply mode to ${command.mode}.` };
250
+ }
251
+
252
+ const fallbackAgentId =
253
+ existing.kind === 'group'
254
+ ? defaultPublicAgentId
255
+ : parsed.isAdmin
256
+ ? defaultAdminAgentId
257
+ : defaultPublicAgentId;
258
+ const next = applySceneStatus(existing, command.kind === 'allow' ? 'allowed' : 'denied');
259
+ sceneRegistry.set(sceneKey, {
260
+ ...next,
261
+ ...(command.kind === 'allow' ? { agentId: existing.agentId || fallbackAgentId } : {}),
262
+ lastSeenAt: now(),
263
+ });
264
+
265
+ return {
266
+ ok: true,
267
+ text: command.kind === 'allow' ? `Allowed scene ${sceneKey}.` : `Denied scene ${sceneKey}.`,
268
+ };
269
+ }
@@ -5,6 +5,37 @@ import {
5
5
  updateBncrSessionStoreEntry,
6
6
  } from '../../openclaw/inbound-session-runtime.ts';
7
7
 
8
+ function formatBncrSessionLabel(displayTo: string): string {
9
+ const raw = String(displayTo || '').trim();
10
+ const parts = raw.split(':');
11
+ if (parts.length === 4 && parts[0] === 'Bncr') {
12
+ const [, platform, groupId, userId] = parts;
13
+ if (platform && groupId && groupId !== '0') return `Bncr:${platform}:Group:${groupId}`;
14
+ if (platform && userId && userId !== '0') return `Bncr:${platform}:User:${userId}`;
15
+ }
16
+ return raw;
17
+ }
18
+
19
+ function formatBncrSessionRouteTarget(displayTo: string): string {
20
+ const raw = String(displayTo || '').trim();
21
+ const parts = raw.split(':');
22
+ if (parts.length === 4 && parts[0] === 'Bncr') {
23
+ const [, platform, groupId, userId] = parts;
24
+ if (platform && groupId && groupId !== '0') return `Bncr:${platform}:${groupId}:0`;
25
+ if (platform && userId && userId !== '0') return `Bncr:${platform}:0:${userId}`;
26
+ }
27
+ return raw;
28
+ }
29
+
30
+ function formatBncrSessionGroupKey(displayTo: string): string | null {
31
+ const raw = String(displayTo || '').trim();
32
+ const parts = raw.split(':');
33
+ if (parts.length !== 4 || parts[0] !== 'Bncr') return null;
34
+ const [, platform, groupId] = parts;
35
+ if (!platform || !groupId || groupId === '0') return null;
36
+ return `${platform.toLowerCase()}:${groupId}`;
37
+ }
38
+
8
39
  type RecordInboundSessionFn = (args: {
9
40
  storePath?: string;
10
41
  sessionKey?: string;
@@ -21,30 +52,35 @@ export function buildBncrInboundSessionIdentityPatch(args: {
21
52
  senderId: string;
22
53
  }) {
23
54
  const { channelId, accountId, chatType, displayTo, senderId } = args;
55
+ const displayLabel = formatBncrSessionLabel(displayTo);
56
+ const routeTarget = formatBncrSessionRouteTarget(displayTo);
57
+ const groupKey = chatType === 'group' ? formatBncrSessionGroupKey(displayTo) : null;
24
58
  return {
25
- label: displayTo,
59
+ label: displayLabel,
60
+ displayName: displayLabel,
26
61
  channel: channelId,
27
62
  chatType,
63
+ ...(groupKey ? { groupId: groupKey } : {}),
28
64
  origin: {
29
- label: displayTo,
65
+ label: displayLabel,
30
66
  provider: channelId,
31
67
  surface: channelId,
32
68
  chatType,
33
69
  from: senderId,
34
- to: displayTo,
70
+ to: routeTarget,
35
71
  accountId,
36
72
  },
37
73
  deliveryContext: {
38
74
  channel: channelId,
39
- to: displayTo,
75
+ to: routeTarget,
40
76
  accountId,
41
77
  },
42
78
  route: {
43
79
  channel: channelId,
44
80
  accountId,
45
- target: { to: displayTo },
81
+ target: { to: routeTarget },
46
82
  },
47
- lastTo: displayTo,
83
+ lastTo: routeTarget,
48
84
  };
49
85
  }
50
86
 
@@ -56,20 +92,93 @@ function normalizeNonEmptyString(value: unknown): string | null {
56
92
  export async function correctBncrInboundSessionLabel(args: {
57
93
  storePath: string;
58
94
  sessionKey: string;
59
- expectedLabel: string;
95
+ expectedPatch: Record<string, unknown>;
60
96
  }) {
61
97
  const storePath = normalizeNonEmptyString(args.storePath);
62
98
  const sessionKey = normalizeNonEmptyString(args.sessionKey);
63
- const expectedLabel = normalizeNonEmptyString(args.expectedLabel);
64
- if (!storePath || !sessionKey || !expectedLabel) return;
99
+ if (!storePath || !sessionKey) return;
100
+
101
+ const expectedPatch = args.expectedPatch;
102
+ const expectedLabel = normalizeNonEmptyString(expectedPatch.label);
103
+ const expectedDisplayName = normalizeNonEmptyString(expectedPatch.displayName);
104
+ const expectedGroupId = normalizeNonEmptyString(expectedPatch.groupId);
105
+ const expectedOriginTo = normalizeNonEmptyString(
106
+ (expectedPatch.origin as { to?: unknown } | undefined)?.to,
107
+ );
108
+ const expectedDeliveryTo = normalizeNonEmptyString(
109
+ (expectedPatch.deliveryContext as { to?: unknown } | undefined)?.to,
110
+ );
111
+ const expectedRouteTo = normalizeNonEmptyString(
112
+ (
113
+ (expectedPatch.route as { target?: { to?: unknown } } | undefined)?.target as
114
+ | { to?: unknown }
115
+ | undefined
116
+ )?.to,
117
+ );
118
+ const expectedLastTo = normalizeNonEmptyString(expectedPatch.lastTo);
119
+ if (
120
+ !expectedLabel ||
121
+ !expectedDisplayName ||
122
+ !expectedOriginTo ||
123
+ !expectedDeliveryTo ||
124
+ !expectedRouteTo ||
125
+ !expectedLastTo
126
+ )
127
+ return;
65
128
 
66
129
  try {
67
130
  await updateBncrSessionStoreEntry({
68
131
  storePath,
69
132
  sessionKey,
70
133
  update: (entry: SessionStoreEntryLike) => {
71
- if (entry?.label === expectedLabel) return null;
72
- return { label: expectedLabel };
134
+ const origin =
135
+ entry?.origin && typeof entry.origin === 'object'
136
+ ? (entry.origin as Record<string, unknown>)
137
+ : {};
138
+ const deliveryContext =
139
+ entry?.deliveryContext && typeof entry.deliveryContext === 'object'
140
+ ? (entry.deliveryContext as Record<string, unknown>)
141
+ : {};
142
+ const route =
143
+ entry?.route && typeof entry.route === 'object'
144
+ ? (entry.route as Record<string, unknown>)
145
+ : {};
146
+ const routeTarget =
147
+ route.target && typeof route.target === 'object'
148
+ ? (route.target as Record<string, unknown>)
149
+ : {};
150
+
151
+ const unchanged =
152
+ entry?.label === expectedLabel &&
153
+ entry?.displayName === expectedDisplayName &&
154
+ normalizeNonEmptyString(entry?.groupId) === expectedGroupId &&
155
+ normalizeNonEmptyString(origin.to) === expectedOriginTo &&
156
+ normalizeNonEmptyString(deliveryContext.to) === expectedDeliveryTo &&
157
+ normalizeNonEmptyString(routeTarget.to) === expectedRouteTo &&
158
+ normalizeNonEmptyString(entry?.lastTo) === expectedLastTo;
159
+ if (unchanged) return null;
160
+
161
+ return {
162
+ ...(expectedGroupId ? { groupId: expectedGroupId } : {}),
163
+ label: expectedLabel,
164
+ displayName: expectedDisplayName,
165
+ origin: {
166
+ ...origin,
167
+ ...(expectedPatch.origin as Record<string, unknown>),
168
+ },
169
+ deliveryContext: {
170
+ ...deliveryContext,
171
+ ...(expectedPatch.deliveryContext as Record<string, unknown>),
172
+ },
173
+ route: {
174
+ ...route,
175
+ ...(expectedPatch.route as Record<string, unknown>),
176
+ target: {
177
+ to: expectedRouteTo,
178
+ },
179
+ },
180
+ lastTo: expectedLastTo,
181
+ };
73
182
  },
74
183
  });
75
184
  } catch (err) {
@@ -108,7 +217,7 @@ export async function recordAndPatchBncrInboundSessionEntry(args: {
108
217
 
109
218
  export function wrapBncrInboundRecordSessionLabelCorrection(args: {
110
219
  recordInboundSession: RecordInboundSessionFn;
111
- expectedLabel: string;
220
+ expectedPatch: Record<string, unknown>;
112
221
  }): RecordInboundSessionFn {
113
222
  return async (recordArgs) => {
114
223
  const result = await args.recordInboundSession(recordArgs);
@@ -116,7 +225,7 @@ export function wrapBncrInboundRecordSessionLabelCorrection(args: {
116
225
  await correctBncrInboundSessionLabel({
117
226
  storePath: recordArgs.storePath,
118
227
  sessionKey: recordArgs.sessionKey,
119
- expectedLabel: args.expectedLabel,
228
+ expectedPatch: args.expectedPatch,
120
229
  });
121
230
  return result;
122
231
  };
@@ -0,0 +1,17 @@
1
+ export function createBncrSessionMetaTaskBarrier() {
2
+ const pending = new Set<Promise<unknown>>();
3
+
4
+ return {
5
+ track(task: Promise<unknown>) {
6
+ if (!task || typeof task.then !== 'function') return;
7
+ pending.add(task);
8
+ void task.finally(() => {
9
+ pending.delete(task);
10
+ });
11
+ },
12
+ async wait() {
13
+ if (pending.size === 0) return;
14
+ await Promise.allSettled([...pending]);
15
+ },
16
+ };
17
+ }