@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
@@ -2,6 +2,13 @@ export type NativeCommand = {
2
2
  command: string;
3
3
  raw: string;
4
4
  body: string;
5
+ argsText: string;
6
+ };
7
+
8
+ export type ParseBncrNativeCommandOptions = {
9
+ allowBareWhoami?: boolean;
10
+ allowBareStatus?: boolean;
11
+ allowBareSessionReset?: boolean;
5
12
  };
6
13
 
7
14
  export type NativeVerboseCommand = {
@@ -10,27 +17,204 @@ export type NativeVerboseCommand = {
10
17
  text: string;
11
18
  };
12
19
 
13
- export function parseBncrNativeCommand(text: string): NativeCommand | null {
20
+ export type NativeHelpCommand = {
21
+ handled: true;
22
+ text: string;
23
+ };
24
+
25
+ export type NativeWhoamiCommand = {
26
+ handled: true;
27
+ text: string;
28
+ };
29
+
30
+ export type NativeStatusCommand = {
31
+ handled: true;
32
+ text: string;
33
+ };
34
+
35
+ export type NativeSessionResetCommand = {
36
+ handled: true;
37
+ reason: 'new' | 'reset';
38
+ text: string;
39
+ };
40
+
41
+ const BNCR_HELP_TEXT = [
42
+ '🦞 Bncr command usage',
43
+ '',
44
+ '📌 Bncr builtins',
45
+ ' • /bncr whoami',
46
+ ' • /bncr verbose on|off|full',
47
+ '',
48
+ '🛡 Scene approval',
49
+ ' • /bncr allow [<SceneId>]',
50
+ ' • /bncr deny [<SceneId>]',
51
+ ' • /bncr bind <agentId> [<SceneId>]',
52
+ ' • /bncr mode',
53
+ ' • /bncr mode help',
54
+ ' • /bncr mode <admin|mention|hybrid|all> [<SceneId>]',
55
+ ' • /bncr revoke [<SceneId>]',
56
+ ' • /bncr list pending',
57
+ ' • /bncr list scenes',
58
+ ].join('\n');
59
+
60
+ const BNCR_NATIVE_COMMANDS = new Set([
61
+ 'help',
62
+ 'new',
63
+ 'reset',
64
+ 'status',
65
+ 'whoami',
66
+ 'verbose',
67
+ 'allow',
68
+ 'deny',
69
+ 'bind',
70
+ 'mode',
71
+ 'revoke',
72
+ 'list',
73
+ ]);
74
+
75
+ export const BNCR_DIRECT_ALLOWED_BARE_COMMANDS = new Set(['whoami', 'status', 'new', 'reset']);
76
+
77
+ export const BNCR_DIRECT_ALLOWED_SUBCOMMANDS = new Set([
78
+ 'help',
79
+ 'whoami',
80
+ 'status',
81
+ 'new',
82
+ 'reset',
83
+ ]);
84
+
85
+ export function parseBncrNativeCommand(
86
+ text: string,
87
+ options?: ParseBncrNativeCommandOptions,
88
+ ): NativeCommand | null {
14
89
  const raw = String(text || '').trim();
15
- if (!raw.startsWith('/')) return null;
16
- const match = raw.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/i);
90
+ const allowBareWhoami = options?.allowBareWhoami !== false;
91
+ const allowBareStatus = options?.allowBareStatus === true;
92
+ const allowBareSessionReset = options?.allowBareSessionReset === true;
93
+ if (allowBareSessionReset && raw.toLowerCase() === '/new') {
94
+ return { command: 'new', raw, body: '/new', argsText: '' };
95
+ }
96
+ if (allowBareSessionReset && raw.toLowerCase() === '/reset') {
97
+ return { command: 'reset', raw, body: '/reset', argsText: '' };
98
+ }
99
+ if (allowBareStatus && raw.toLowerCase() === '/status') {
100
+ return { command: 'status', raw, body: '/status', argsText: '' };
101
+ }
102
+ if (allowBareWhoami && raw.toLowerCase() === '/whoami') {
103
+ return { command: 'whoami', raw, body: '/whoami', argsText: '' };
104
+ }
105
+ if (!raw.startsWith('/bncr')) return null;
106
+ const match = raw.match(/^\/bncr(?:@[A-Za-z0-9_]+)?(?:\s+([^\s]+)(?:\s+([\s\S]*))?)?$/i);
17
107
  if (!match) return null;
18
108
 
19
- const command = String(match[1] || '')
109
+ const command = String(match[1] || 'help')
20
110
  .trim()
21
111
  .toLowerCase();
22
112
  if (!command) return null;
113
+ if (!BNCR_NATIVE_COMMANDS.has(command)) return null;
114
+
115
+ const argsText = String(match[2] || '').trim();
116
+ const body =
117
+ command === 'help' ? '/commands' : [`/${command}`, argsText].filter(Boolean).join(' ');
118
+ return { command, raw, body, argsText };
119
+ }
120
+
121
+ export function parseBncrUnsupportedDirectCommand(text: string): {
122
+ command: string;
123
+ raw: string;
124
+ } | null {
125
+ const raw = String(text || '').trim();
126
+ if (!raw.startsWith('/')) return null;
127
+
128
+ const bareMatch = raw.match(/^\/([A-Za-z0-9_]+)(?:@[A-Za-z0-9_]+)?(?:\s+.*)?$/);
129
+ if (!bareMatch) return null;
130
+ const bareCommand = String(bareMatch[1] || '')
131
+ .trim()
132
+ .toLowerCase();
133
+ if (!bareCommand) return null;
134
+
135
+ if (bareCommand === 'bncr') {
136
+ const subMatch = raw.match(/^\/bncr(?:@[A-Za-z0-9_]+)?(?:\s+([A-Za-z0-9_-]+))?/i);
137
+ const subCommand =
138
+ String(subMatch?.[1] || '')
139
+ .trim()
140
+ .toLowerCase() || 'help';
141
+ if (BNCR_DIRECT_ALLOWED_SUBCOMMANDS.has(subCommand)) return null;
142
+ return { command: `bncr ${subCommand}`, raw };
143
+ }
23
144
 
24
- const rest = String(match[2] || '').trim();
25
- const body = command === 'help' ? ['/commands', rest].filter(Boolean).join(' ') : raw;
26
- return { command, raw, body };
145
+ if (BNCR_DIRECT_ALLOWED_BARE_COMMANDS.has(bareCommand)) return null;
146
+ return { command: bareCommand, raw };
147
+ }
148
+
149
+ export function resolveBncrNativeHelpCommand(command: NativeCommand): NativeHelpCommand | null {
150
+ if (command.command !== 'help') return null;
151
+ return { handled: true, text: BNCR_HELP_TEXT };
152
+ }
153
+
154
+ export function resolveBncrNativeWhoamiCommand(args: {
155
+ command: NativeCommand;
156
+ platform: string;
157
+ groupId: string;
158
+ groupName?: string;
159
+ userId: string;
160
+ userName?: string;
161
+ isGroup: boolean;
162
+ isAdmin: boolean;
163
+ }): NativeWhoamiCommand | null {
164
+ if (args.command.command !== 'whoami') return null;
165
+ const lines = ['🧭 Bncr Identity', ''];
166
+ lines.push(`Platform: ${args.platform || '(unknown)'}`);
167
+ lines.push(`User: ${args.userName || '(unknown)'} (${args.userId || '0'})`);
168
+ if (args.isGroup) {
169
+ lines.push(`Group: ${args.groupName || '(unknown)'} (${args.groupId || '0'})`);
170
+ lines.push(`Scene: ${args.platform || '(unknown)'}:${args.groupId || '0'}`);
171
+ } else {
172
+ lines.push(`Scene: ${args.platform || '(unknown)'}:${args.userId || '0'}`);
173
+ }
174
+ lines.push(`Admin: ${args.isAdmin ? 'true' : 'false'}`);
175
+ return { handled: true, text: lines.join('\n') };
176
+ }
177
+
178
+ export function resolveBncrNativeStatusCommand(args: {
179
+ command: NativeCommand;
180
+ accountId: string;
181
+ platform: string;
182
+ userId: string;
183
+ userName?: string;
184
+ resolvedAgentId: string;
185
+ sessionKey: string;
186
+ }): NativeStatusCommand | null {
187
+ if (args.command.command !== 'status') return null;
188
+ const lines = ['🦞 Bncr Status', ''];
189
+ lines.push(`Channel: bncr`);
190
+ lines.push(`Account: ${args.accountId || 'Primary'}`);
191
+ lines.push(`User: ${args.userName || '(unknown)'} (${args.userId || '0'})`);
192
+ lines.push(`Scene: ${args.platform || '(unknown)'}:${args.userId || '0'}`);
193
+ lines.push(`Agent: ${args.resolvedAgentId || '(unknown)'}`);
194
+ lines.push(`SessionKey: ${args.sessionKey || '(unknown)'}`);
195
+ return { handled: true, text: lines.join('\n') };
196
+ }
197
+
198
+ export function resolveBncrNativeSessionResetCommand(args: {
199
+ command: NativeCommand;
200
+ }): NativeSessionResetCommand | null {
201
+ if (args.command.command !== 'new' && args.command.command !== 'reset') return null;
202
+ const reason = args.command.command === 'new' ? 'new' : 'reset';
203
+ return {
204
+ handled: true,
205
+ reason,
206
+ text:
207
+ reason === 'new'
208
+ ? 'Started a new session for this private chat.'
209
+ : 'Reset the current session for this private chat.',
210
+ };
27
211
  }
28
212
 
29
213
  export function resolveBncrNativeVerboseCommand(
30
214
  command: NativeCommand,
31
215
  ): NativeVerboseCommand | null {
32
216
  if (command.command !== 'verbose') return null;
33
- const rawLevel = String(command.raw.slice('/verbose'.length) || '')
217
+ const rawLevel = String(command.argsText || '')
34
218
  .trim()
35
219
  .toLowerCase();
36
220
  if (!rawLevel || rawLevel === 'status') {
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { normalizeAccountId } from '../../core/accounts.ts';
3
3
  import { extractInlineTaskKey } from '../../core/targets.ts';
4
4
  import type { BncrRoute } from '../../core/types.ts';
5
- import type { BncrInboundParamsInput } from './contracts.ts';
5
+ import type { BncrInboundMediaItem, BncrInboundParamsInput } from './contracts.ts';
6
6
 
7
7
  function asString(v: unknown, fallback = ''): string {
8
8
  if (typeof v === 'string') return v;
@@ -10,6 +10,84 @@ function asString(v: unknown, fallback = ''): string {
10
10
  return String(v);
11
11
  }
12
12
 
13
+ function asBoolean(v: unknown, fallback = false): boolean {
14
+ if (typeof v === 'boolean') return v;
15
+ if (typeof v === 'number') return v !== 0;
16
+ if (typeof v === 'string') {
17
+ const raw = v.trim().toLowerCase();
18
+ if (!raw) return fallback;
19
+ if (['true', '1', 'yes', 'y', 'on'].includes(raw)) return true;
20
+ if (['false', '0', 'no', 'n', 'off'].includes(raw)) return false;
21
+ }
22
+ return fallback;
23
+ }
24
+
25
+ function asStringArray(v: unknown): string[] {
26
+ if (Array.isArray(v)) {
27
+ return v.map((item) => asString(item).trim()).filter(Boolean);
28
+ }
29
+ if (typeof v === 'string') {
30
+ return v
31
+ .split(',')
32
+ .map((item) => item.trim())
33
+ .filter(Boolean);
34
+ }
35
+ return [];
36
+ }
37
+
38
+ function asInboundMediaItems(params: BncrInboundParamsInput): BncrInboundMediaItem[] {
39
+ const normalized: BncrInboundMediaItem[] = [];
40
+ const rawList = Array.isArray(params?.mediaList) ? params.mediaList : [];
41
+ for (const item of rawList) {
42
+ if (!item || typeof item !== 'object') continue;
43
+ const path = asString((item as { path?: unknown }).path || '').trim();
44
+ const base64 = asString((item as { base64?: unknown }).base64 || '').trim();
45
+ const mimeType = asString((item as { mimeType?: unknown }).mimeType || '').trim();
46
+ const fileName = asString((item as { fileName?: unknown }).fileName || '').trim();
47
+ const type = asString((item as { type?: unknown }).type || '').trim();
48
+ const transferId = asString((item as { transferId?: unknown }).transferId || '').trim();
49
+ if (!path && !base64) continue;
50
+ normalized.push({
51
+ ...(path ? { path } : {}),
52
+ ...(base64 ? { base64 } : {}),
53
+ ...(mimeType ? { mimeType } : {}),
54
+ ...(fileName ? { fileName } : {}),
55
+ ...(type ? { type } : {}),
56
+ ...(transferId ? { transferId } : {}),
57
+ });
58
+ }
59
+
60
+ if (normalized.length > 0) return normalized;
61
+
62
+ const legacyPath = asString(params?.path || '').trim();
63
+ const legacyBase64 = asString(params?.base64 || '').trim();
64
+ const legacyMimeType = asString(params?.mimeType || '').trim();
65
+ const legacyFileName = asString(params?.fileName || '').trim();
66
+ const legacyType = asString(params?.type || '').trim();
67
+ const legacyTransferId = asString((params as { transferId?: unknown })?.transferId || '').trim();
68
+ if (legacyPath || legacyBase64) {
69
+ return [
70
+ {
71
+ ...(legacyPath ? { path: legacyPath } : {}),
72
+ ...(legacyBase64 ? { base64: legacyBase64 } : {}),
73
+ ...(legacyMimeType ? { mimeType: legacyMimeType } : {}),
74
+ ...(legacyFileName ? { fileName: legacyFileName } : {}),
75
+ ...(legacyType ? { type: legacyType } : {}),
76
+ ...(legacyTransferId ? { transferId: legacyTransferId } : {}),
77
+ },
78
+ ];
79
+ }
80
+
81
+ const legacyPaths = asStringArray(params?.paths);
82
+ return legacyPaths.map((item) => ({
83
+ path: item,
84
+ ...(legacyMimeType ? { mimeType: legacyMimeType } : {}),
85
+ ...(legacyFileName ? { fileName: legacyFileName } : {}),
86
+ ...(legacyType ? { type: legacyType } : {}),
87
+ ...(legacyTransferId ? { transferId: legacyTransferId } : {}),
88
+ }));
89
+ }
90
+
13
91
  export function inboundDedupKey(params: {
14
92
  accountId: string;
15
93
  platform: string;
@@ -36,24 +114,30 @@ export function inboundDedupKey(params: {
36
114
  return `${accountId}|${platform}|${groupId}|${userId}|hash:${digest}`;
37
115
  }
38
116
 
39
- export function resolveChatType(_route: BncrRoute): 'direct' | 'group' {
40
- // Compatibility boundary: bncr currently records and dispatches all conversations as direct
41
- // sessions even when the display scope contains a group id. Do not change this to true
42
- // group semantics without updating session routing, reply target policy, and requireMention
43
- // behavior together.
117
+ export function resolveChatType(route: BncrRoute, isGroup: boolean): 'direct' | 'group' {
118
+ if (isGroup) return 'group';
119
+ if (route.groupId !== '0') return 'group';
44
120
  return 'direct';
45
121
  }
46
122
 
47
123
  export function parseBncrInboundParams(params: BncrInboundParamsInput) {
48
124
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
125
+ const protocolVersion = asString(params?.protocolVersion || '').trim() || undefined;
126
+ const capabilities = asStringArray(params?.capabilities);
49
127
  const platform = asString(params?.platform || '').trim();
50
128
  const groupId = asString(params?.groupId || '0').trim() || '0';
129
+ const groupName = asString(params?.groupName || '').trim();
51
130
  const userId = asString(params?.userId || '').trim();
131
+ const userName = asString(params?.userName || '').trim();
52
132
  const sessionKeyfromroute = asString(params?.sessionKey || '').trim();
53
133
  const providedOriginatingTo =
54
134
  asString(params?.originatingTo || params?.providedOriginatingTo || params?.to || '').trim() ||
55
135
  undefined;
56
136
  const clientId = asString(params?.clientId || '').trim() || undefined;
137
+ const bridgeId = asString(params?.bridgeId || params?.clientId || '').trim() || undefined;
138
+ const bridgeName = asString(params?.bridgeName || 'Bncr').trim() || 'Bncr';
139
+ const isGroup = asBoolean(params?.isGroup, groupId !== '0');
140
+ const isAdmin = asBoolean(params?.isAdmin, false);
57
141
 
58
142
  const route: BncrRoute = {
59
143
  platform,
@@ -63,11 +147,17 @@ export function parseBncrInboundParams(params: BncrInboundParamsInput) {
63
147
 
64
148
  const text = asString(params?.msg || '');
65
149
  const msgType = asString(params?.type || 'text') || 'text';
150
+ const mediaItems = asInboundMediaItems(params);
66
151
  const mediaBase64 = asString(params?.base64 || '');
67
152
  const mediaPathFromTransfer = asString(params?.path || '').trim();
68
153
  const mimeType = asString(params?.mimeType || '').trim() || undefined;
69
154
  const fileName = asString(params?.fileName || '').trim() || undefined;
70
155
  const msgId = asString(params?.msgId || '').trim() || undefined;
156
+ const shouldRespond = asBoolean(params?.shouldRespond, false);
157
+ const triggerKind = asString(params?.triggerKind || 'none').trim() || 'none';
158
+ const botName = asString(params?.botName || '').trim();
159
+ const isBotMentioned = asBoolean(params?.isBotMentioned, false);
160
+ const isReplyToBot = asBoolean(params?.isReplyToBot, false);
71
161
 
72
162
  const dedupKey = inboundDedupKey({
73
163
  accountId,
@@ -79,29 +169,44 @@ export function parseBncrInboundParams(params: BncrInboundParamsInput) {
79
169
  mediaBase64,
80
170
  });
81
171
 
172
+ const peerKind = resolveChatType(route, isGroup);
82
173
  const peer = {
83
- kind: resolveChatType(route),
84
- id: route.groupId === '0' ? route.userId : route.groupId,
174
+ kind: peerKind,
175
+ id: peerKind === 'group' ? route.groupId : route.userId,
85
176
  } as const;
86
177
 
87
178
  const extracted = extractInlineTaskKey(text);
88
179
 
89
180
  return {
90
181
  accountId,
182
+ protocolVersion,
183
+ capabilities,
91
184
  platform,
92
185
  groupId,
186
+ groupName,
93
187
  userId,
188
+ userName,
94
189
  sessionKeyfromroute,
95
190
  providedOriginatingTo,
96
191
  clientId,
192
+ bridgeId,
193
+ bridgeName,
194
+ isGroup,
195
+ isAdmin,
97
196
  route,
98
197
  text,
99
198
  msgType,
199
+ mediaItems,
100
200
  mediaBase64,
101
201
  mediaPathFromTransfer,
102
202
  mimeType,
103
203
  fileName,
104
204
  msgId,
205
+ shouldRespond,
206
+ triggerKind,
207
+ botName,
208
+ isBotMentioned,
209
+ isReplyToBot,
105
210
  dedupKey,
106
211
  peer,
107
212
  extracted,
@@ -0,0 +1,62 @@
1
+ import { emitBncrLogLine } from '../../core/logging.ts';
2
+
3
+ const bncrReplyDispatchChains = new Map<string, Promise<void>>();
4
+
5
+ export async function runBncrReplyDispatchSerial<T>(
6
+ sessionKey: string,
7
+ task: () => Promise<T>,
8
+ meta?: { msgId?: string | null; to?: string | null; debugEnabled?: boolean },
9
+ ) {
10
+ const key = String(sessionKey || '').trim();
11
+ if (!key) return await task();
12
+
13
+ const metaSuffix = `|msgId=${String(meta?.msgId || '-')}|to=${String(meta?.to || '-')}`;
14
+
15
+ const previous = bncrReplyDispatchChains.get(key) || Promise.resolve();
16
+ let release!: () => void;
17
+ const current = new Promise<void>((resolve) => {
18
+ release = resolve;
19
+ });
20
+ const chain = previous.then(() => current);
21
+ bncrReplyDispatchChains.set(key, chain);
22
+
23
+ const debugGate = () => meta?.debugEnabled === true;
24
+
25
+ emitBncrLogLine(
26
+ 'info',
27
+ `[bncr] reply-serial queued|sessionKey=${key}${metaSuffix}`,
28
+ { debugOnly: true },
29
+ debugGate,
30
+ );
31
+ await previous;
32
+ emitBncrLogLine(
33
+ 'info',
34
+ `[bncr] reply-serial acquired|sessionKey=${key}${metaSuffix}`,
35
+ { debugOnly: true },
36
+ debugGate,
37
+ );
38
+ try {
39
+ return await task();
40
+ } finally {
41
+ emitBncrLogLine(
42
+ 'info',
43
+ `[bncr] reply-serial releasing|sessionKey=${key}${metaSuffix}`,
44
+ { debugOnly: true },
45
+ debugGate,
46
+ );
47
+ release();
48
+ if (bncrReplyDispatchChains.get(key) === chain) {
49
+ bncrReplyDispatchChains.delete(key);
50
+ }
51
+ emitBncrLogLine(
52
+ 'info',
53
+ `[bncr] reply-serial released|sessionKey=${key}${metaSuffix}`,
54
+ { debugOnly: true },
55
+ debugGate,
56
+ );
57
+ }
58
+ }
59
+
60
+ export function resetBncrReplyDispatchSerialForTest() {
61
+ bncrReplyDispatchChains.clear();
62
+ }