@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
@@ -1,6 +1,7 @@
1
1
  import { emitBncrLogLine } from '../../core/logging.ts';
2
2
  import { resolveBncrChannelPolicy } from '../../core/policy.ts';
3
3
  import {
4
+ readBncrSessionUpdatedAt,
4
5
  recordBncrInboundSession,
5
6
  resolveBncrPinnedMainDmOwnerFromAllowlist,
6
7
  } from '../../openclaw/inbound-session-runtime.ts';
@@ -17,9 +18,97 @@ import type {
17
18
  ParsedInbound,
18
19
  } from './dispatch-prep.ts';
19
20
  import { buildBncrInboundRecordUpdateLastRoute } from './last-route.ts';
21
+ import { parseBncrNativeCommand } from './native-command.ts';
20
22
  import { buildBncrReplyConfig } from './reply-config.ts';
23
+ import { runBncrReplyDispatchSerial } from './reply-dispatch-serial.ts';
21
24
  import { resolveBncrChannelInboundRuntime } from './runtime-compat.ts';
22
- import { wrapBncrInboundRecordSessionLabelCorrection } from './session-label.ts';
25
+ import {
26
+ buildBncrInboundSessionIdentityPatch,
27
+ correctBncrInboundSessionLabel,
28
+ wrapBncrInboundRecordSessionLabelCorrection,
29
+ } from './session-label.ts';
30
+ import { createBncrSessionMetaTaskBarrier } from './session-meta-task.ts';
31
+
32
+ function mergeBncrCommandOwnerAllowFrom(args: {
33
+ cfg: BncrInboundConfig;
34
+ parsed: ParsedInbound;
35
+ isBncrNativeCommand: boolean;
36
+ senderIdForContext: string;
37
+ }) {
38
+ const { cfg, parsed, isBncrNativeCommand, senderIdForContext } = args;
39
+ if (parsed.isAdmin !== true || isBncrNativeCommand) return cfg;
40
+ const senderId = String(senderIdForContext || '').trim();
41
+ if (!senderId) return cfg;
42
+
43
+ const currentCommands = (cfg.commands || {}) as { ownerAllowFrom?: string[] };
44
+ const currentOwnerAllowFrom = Array.isArray(currentCommands.ownerAllowFrom)
45
+ ? currentCommands.ownerAllowFrom
46
+ : [];
47
+ if (currentOwnerAllowFrom.includes(senderId)) return cfg;
48
+
49
+ return {
50
+ ...cfg,
51
+ commands: {
52
+ ...currentCommands,
53
+ ownerAllowFrom: [...currentOwnerAllowFrom, senderId],
54
+ },
55
+ } satisfies BncrInboundConfig;
56
+ }
57
+
58
+ function sleep(ms: number) {
59
+ return new Promise((resolve) => setTimeout(resolve, ms));
60
+ }
61
+
62
+ async function waitForBncrReplySessionQuiescence(args: {
63
+ api: BncrInboundApi;
64
+ storePath: string;
65
+ sessionKey: string;
66
+ msgId?: string | null;
67
+ to: string;
68
+ debugEnabled?: boolean;
69
+ }) {
70
+ const settleWindowMs = 120;
71
+ const pollIntervalMs = 40;
72
+ const maxWaitMs = 1500;
73
+ const startedAt = Date.now();
74
+ const debugGate = () => args.debugEnabled === true;
75
+ let lastUpdatedAt = null as number | null;
76
+ let stableSince = Date.now();
77
+
78
+ const readUpdatedAt = async () => {
79
+ try {
80
+ const updatedAt = await readBncrSessionUpdatedAt(args.api, {
81
+ storePath: args.storePath,
82
+ sessionKey: args.sessionKey,
83
+ });
84
+ return typeof updatedAt === 'number' && Number.isFinite(updatedAt) ? updatedAt : null;
85
+ } catch {
86
+ return null;
87
+ }
88
+ };
89
+
90
+ lastUpdatedAt = await readUpdatedAt();
91
+
92
+ while (Date.now() - startedAt < maxWaitMs) {
93
+ await sleep(pollIntervalMs);
94
+ const currentUpdatedAt = await readUpdatedAt();
95
+ if (currentUpdatedAt !== lastUpdatedAt) {
96
+ lastUpdatedAt = currentUpdatedAt;
97
+ stableSince = Date.now();
98
+ continue;
99
+ }
100
+ if (Date.now() - stableSince >= settleWindowMs) {
101
+ return;
102
+ }
103
+ }
104
+
105
+ emitBncrLogLine(
106
+ 'warn',
107
+ `[bncr] reply-dispatch settle timeout|sessionKey=${args.sessionKey}|msgId=${args.msgId || '-'}|to=${args.to}|waitMs=${Date.now() - startedAt}`,
108
+ { debugOnly: true },
109
+ debugGate,
110
+ );
111
+ }
23
112
 
24
113
  export async function runBncrInboundReplyDispatch(args: {
25
114
  api: BncrInboundApi;
@@ -34,6 +123,8 @@ export async function runBncrInboundReplyDispatch(args: {
34
123
  resolution: BncrInboundConversationResolution;
35
124
  replyRouteFact: BncrInboundReplyRouteFact;
36
125
  senderIdForContext: string;
126
+ senderDisplayName: string;
127
+ shouldDispatch: boolean;
37
128
  setInboundActivity: (accountId: string, at: number) => void;
38
129
  scheduleSave: () => void;
39
130
  enqueueFromReply: BncrEnqueueFromReply;
@@ -51,12 +142,20 @@ export async function runBncrInboundReplyDispatch(args: {
51
142
  resolution,
52
143
  replyRouteFact,
53
144
  senderIdForContext,
145
+ shouldDispatch,
54
146
  setInboundActivity,
55
147
  scheduleSave,
56
148
  enqueueFromReply,
57
149
  } = args;
58
150
 
59
151
  const effectiveReply = buildBncrReplyConfig(cfg);
152
+ const sessionIdentityPatch = buildBncrInboundSessionIdentityPatch({
153
+ channelId,
154
+ accountId: resolution.accountId,
155
+ chatType: resolution.chatType,
156
+ displayTo: resolution.canonicalTo,
157
+ senderId: senderIdForContext,
158
+ });
60
159
  const channelPolicy = resolveBncrChannelPolicy(cfg?.channels?.bncr || {});
61
160
  const pinnedMainDmOwner =
62
161
  peer.kind === 'direct'
@@ -76,87 +175,163 @@ export async function runBncrInboundReplyDispatch(args: {
76
175
  sessionKey: resolution.dispatchSessionKey,
77
176
  pinnedMainDmOwner,
78
177
  });
178
+ const isBncrNativeCommand =
179
+ parseBncrNativeCommand(rawBody, {
180
+ allowBareWhoami: parsed.isAdmin !== true,
181
+ }) !== null;
182
+ const commandDispatchCfg = mergeBncrCommandOwnerAllowFrom({
183
+ cfg,
184
+ parsed,
185
+ isBncrNativeCommand,
186
+ senderIdForContext,
187
+ });
79
188
 
80
- await resolveBncrChannelInboundRuntime(api).run({
81
- channel: channelId,
82
- accountId: resolution.accountId,
83
- raw: parsed,
84
- adapter: {
85
- ingest: () => ({
86
- id: msgId ?? `${resolution.canonicalTo}:${Date.now()}`,
87
- timestamp: Date.now(),
88
- rawText: rawBody,
89
- textForAgent: ctxPayload.BodyForAgent,
90
- textForCommands: ctxPayload.CommandBody,
91
- raw: parsed,
92
- }),
93
- resolveTurn: () => ({
189
+ if (!shouldDispatch) {
190
+ await wrapBncrInboundRecordSessionLabelCorrection({
191
+ recordInboundSession: recordBncrInboundSession as (
192
+ ...args: unknown[]
193
+ ) => Promise<unknown> | unknown,
194
+ expectedPatch: sessionIdentityPatch,
195
+ })({
196
+ storePath,
197
+ sessionKey: resolution.resolvedRoute.sessionKey,
198
+ ctx: ctxPayload,
199
+ updateLastRoute,
200
+ onRecordError: (err: unknown) => {
201
+ emitBncrLogLine('warn', `[bncr] inbound record session failed: ${String(err)}`);
202
+ },
203
+ });
204
+
205
+ const inboundAt = Date.now();
206
+ setInboundActivity(resolution.accountId, inboundAt);
207
+ scheduleSave();
208
+ return;
209
+ }
210
+
211
+ await runBncrReplyDispatchSerial(
212
+ resolution.dispatchSessionKey,
213
+ async () =>
214
+ resolveBncrChannelInboundRuntime(api).run({
94
215
  channel: channelId,
95
216
  accountId: resolution.accountId,
96
- routeSessionKey: resolution.resolvedRoute.sessionKey,
97
- storePath,
98
- ctxPayload,
99
- recordInboundSession: wrapBncrInboundRecordSessionLabelCorrection({
100
- recordInboundSession: recordBncrInboundSession as (
101
- ...args: unknown[]
102
- ) => Promise<unknown> | unknown,
103
- expectedLabel: resolution.canonicalTo,
104
- }),
105
- record: {
106
- updateLastRoute,
107
- onRecordError: (err: unknown) => {
108
- emitBncrLogLine('warn', `[bncr] inbound record session failed: ${String(err)}`);
109
- },
110
- },
111
- runDispatch: () =>
112
- dispatchOpenClawReplyWithBufferedBlockDispatcher(api, {
113
- ctx: ctxPayload,
114
- cfg: effectiveReply.replyCfg,
115
- dispatcherOptions: {
116
- deliver: async (
117
- payload: {
118
- text?: string;
119
- mediaUrl?: string;
120
- mediaUrls?: string[];
121
- audioAsVoice?: boolean;
122
- },
123
- info?: { kind?: 'tool' | 'block' | 'final' },
124
- ) => {
125
- const kind = info?.kind;
126
- const shouldForwardTool = effectiveReply.blockStreaming && effectiveReply.allowTool;
127
-
128
- if (kind === 'tool' && !shouldForwardTool) {
129
- return;
130
- }
131
-
132
- await enqueueFromReply({
133
- accountId: replyRouteFact.accountId,
134
- sessionKey: replyRouteFact.sessionKey,
135
- route: replyRouteFact.route,
136
- payload: {
137
- text: payload.text,
138
- mediaUrl: payload.mediaUrl,
139
- mediaUrls: payload.mediaUrls,
140
- kind: kind || 'final',
141
- replyToId: msgId || undefined,
217
+ raw: parsed,
218
+ adapter: {
219
+ ingest: () => ({
220
+ id: msgId ?? `${resolution.canonicalTo}:${Date.now()}`,
221
+ timestamp: Date.now(),
222
+ rawText: rawBody,
223
+ textForAgent: ctxPayload.BodyForAgent,
224
+ textForCommands: ctxPayload.CommandBody,
225
+ raw: parsed,
226
+ }),
227
+ preflight: () =>
228
+ shouldDispatch
229
+ ? undefined
230
+ : {
231
+ admission: {
232
+ kind: 'observeOnly' as const,
233
+ reason: 'bncr-group-mode-no-reply',
142
234
  },
143
- });
235
+ },
236
+ resolveTurn: () => {
237
+ const sessionMetaBarrier = createBncrSessionMetaTaskBarrier();
238
+ return {
239
+ channel: channelId,
240
+ accountId: resolution.accountId,
241
+ routeSessionKey: resolution.resolvedRoute.sessionKey,
242
+ storePath,
243
+ ctxPayload,
244
+ recordInboundSession: wrapBncrInboundRecordSessionLabelCorrection({
245
+ recordInboundSession: recordBncrInboundSession as (
246
+ ...args: unknown[]
247
+ ) => Promise<unknown> | unknown,
248
+ expectedPatch: sessionIdentityPatch,
249
+ }),
250
+ record: {
251
+ updateLastRoute,
252
+ onRecordError: (err: unknown) => {
253
+ emitBncrLogLine('warn', `[bncr] inbound record session failed: ${String(err)}`);
254
+ },
255
+ trackSessionMetaTask: (task: Promise<unknown>) => {
256
+ sessionMetaBarrier.track(task);
257
+ },
144
258
  },
145
- onError: (err: unknown) => {
146
- emitBncrLogLine('error', `[bncr] outbound reply failed: ${String(err)}`);
259
+ runDispatch: async () => {
260
+ await sessionMetaBarrier.wait();
261
+ await correctBncrInboundSessionLabel({
262
+ storePath,
263
+ sessionKey: resolution.dispatchSessionKey,
264
+ expectedPatch: sessionIdentityPatch,
265
+ });
266
+ return Promise.resolve(
267
+ dispatchOpenClawReplyWithBufferedBlockDispatcher(api, {
268
+ ctx: ctxPayload,
269
+ cfg: buildBncrReplyConfig(commandDispatchCfg).replyCfg,
270
+ dispatcherOptions: {
271
+ deliver: async (
272
+ payload: {
273
+ text?: string;
274
+ mediaUrl?: string;
275
+ mediaUrls?: string[];
276
+ audioAsVoice?: boolean;
277
+ },
278
+ info?: { kind?: 'tool' | 'block' | 'final' },
279
+ ) => {
280
+ const kind = info?.kind;
281
+ const shouldForwardTool =
282
+ effectiveReply.blockStreaming && effectiveReply.allowTool;
283
+
284
+ if (kind === 'tool' && !shouldForwardTool) {
285
+ return;
286
+ }
287
+
288
+ await enqueueFromReply({
289
+ accountId: replyRouteFact.accountId,
290
+ sessionKey: replyRouteFact.sessionKey,
291
+ route: replyRouteFact.route,
292
+ payload: {
293
+ text: payload.text,
294
+ mediaUrl: payload.mediaUrl,
295
+ mediaUrls: payload.mediaUrls,
296
+ kind: kind || 'final',
297
+ replyToId: msgId || undefined,
298
+ },
299
+ });
300
+ },
301
+ onError: (err: unknown) => {
302
+ emitBncrLogLine('error', `[bncr] outbound reply failed: ${String(err)}`);
303
+ },
304
+ },
305
+ replyOptions: {
306
+ disableBlockStreaming: !effectiveReply.blockStreaming,
307
+ shouldEmitToolResult: effectiveReply.allowTool ? () => true : undefined,
308
+ },
309
+ }),
310
+ ).finally(async () => {
311
+ await waitForBncrReplySessionQuiescence({
312
+ api,
313
+ storePath,
314
+ sessionKey: resolution.dispatchSessionKey,
315
+ msgId,
316
+ to: resolution.canonicalTo,
317
+ debugEnabled: cfg?.channels?.bncr?.debug?.verbose === true,
318
+ });
319
+ await correctBncrInboundSessionLabel({
320
+ storePath,
321
+ sessionKey: resolution.dispatchSessionKey,
322
+ expectedPatch: sessionIdentityPatch,
323
+ });
324
+ });
147
325
  },
148
- },
149
- replyOptions: {
150
- disableBlockStreaming: !effectiveReply.blockStreaming,
151
- shouldEmitToolResult: effectiveReply.allowTool ? () => true : undefined,
152
- },
153
- }),
326
+ };
327
+ },
328
+ onFinalize: () => {
329
+ const inboundAt = Date.now();
330
+ setInboundActivity(resolution.accountId, inboundAt);
331
+ scheduleSave();
332
+ },
333
+ },
154
334
  }),
155
- onFinalize: () => {
156
- const inboundAt = Date.now();
157
- setInboundActivity(resolution.accountId, inboundAt);
158
- scheduleSave();
159
- },
160
- },
161
- });
335
+ { msgId, to: resolution.canonicalTo },
336
+ );
162
337
  }
@@ -0,0 +1,300 @@
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 formatSceneDetailsLine(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 labelPart = namePart ? ` name=${namePart}` : '';
154
+ return ` Details: status=${scene.status} id=${idPart}${labelPart}`;
155
+ }
156
+
157
+ function formatSceneEntry(scene: BncrSceneRecord): string {
158
+ return [` SceneId: ${scene.sceneKey}`, formatSceneDetailsLine(scene)].join('\n');
159
+ }
160
+
161
+ function buildSceneGroupTitle(scene: BncrSceneRecord): string {
162
+ const agentId = normalizeToken(scene.agentId || '') || 'public';
163
+ if (scene.kind === 'group') {
164
+ const mode = normalizeToken(scene.groupReplyMode || '') || 'admin';
165
+ return `👥 Group Chat ${agentId} ${mode}`;
166
+ }
167
+ return `📱 Private Chat ${agentId}`;
168
+ }
169
+
170
+ function formatSceneGroups(scenes: BncrSceneRecord[]): string {
171
+ const directGrouped = new Map<string, string[]>();
172
+ const groupGrouped = new Map<string, string[]>();
173
+ for (const scene of scenes) {
174
+ const title = buildSceneGroupTitle(scene);
175
+ const target = scene.kind === 'group' ? groupGrouped : directGrouped;
176
+ const current = target.get(title) || [];
177
+ current.push(formatSceneEntry(scene));
178
+ target.set(title, current);
179
+ }
180
+
181
+ const orderedGroups = [
182
+ ...Array.from(directGrouped.entries()),
183
+ ...Array.from(groupGrouped.entries()).sort(([left], [right]) => left.localeCompare(right)),
184
+ ];
185
+
186
+ return orderedGroups
187
+ .map(([title, entries]) => [title, '', entries.join('\n\n')].join('\n'))
188
+ .join('\n\n');
189
+ }
190
+
191
+ function applySceneStatus(scene: BncrSceneRecord, status: BncrSceneStatus): BncrSceneRecord {
192
+ return {
193
+ ...scene,
194
+ status,
195
+ };
196
+ }
197
+
198
+ export function executeSceneAdminCommand(args: {
199
+ parsed: ParsedInbound;
200
+ command: BncrSceneAdminCommand;
201
+ sceneRegistry: Map<string, BncrSceneRecord>;
202
+ defaultAdminAgentId: string;
203
+ defaultPublicAgentId: string;
204
+ now: () => number;
205
+ }): { ok: true; text: string } | { ok: false; text: string } {
206
+ const { parsed, command, sceneRegistry, defaultAdminAgentId, defaultPublicAgentId, now } = args;
207
+
208
+ if (!parsed.isAdmin) {
209
+ return { ok: false, text: 'Admin permission required.' };
210
+ }
211
+
212
+ if (command.kind === 'list') {
213
+ const scenes = Array.from(sceneRegistry.values())
214
+ .filter((scene) => (command.scope === 'pending' ? scene.status === 'pending' : true))
215
+ .sort((a, b) => a.lastSeenAt - b.lastSeenAt);
216
+ if (scenes.length === 0) {
217
+ return {
218
+ ok: true,
219
+ text: command.scope === 'pending' ? 'No pending scenes.' : 'No scenes recorded.',
220
+ };
221
+ }
222
+ return { ok: true, text: formatSceneGroups(scenes) };
223
+ }
224
+
225
+ if (command.kind === 'mode-help') {
226
+ return { ok: true, text: MODE_HELP_TEXT };
227
+ }
228
+
229
+ if (command.kind === 'mode-get') {
230
+ const sceneKey = command.sceneKey || resolveCurrentGroupSceneKey(parsed);
231
+ if (!sceneKey) {
232
+ return { ok: false, text: 'Current group mode query only works inside a group chat.' };
233
+ }
234
+ const existingScene = sceneRegistry.get(sceneKey);
235
+ if (!existingScene) {
236
+ return { ok: false, text: `Scene not found: ${sceneKey}` };
237
+ }
238
+ if (existingScene.kind !== 'group') {
239
+ return { ok: false, text: `Scene ${sceneKey} is not a group scene.` };
240
+ }
241
+ return {
242
+ ok: true,
243
+ text: `Current ${sceneKey} reply mode is ${existingScene.groupReplyMode || 'admin'}.`,
244
+ };
245
+ }
246
+
247
+ const sceneKey = command.sceneKey || resolveCurrentGroupSceneKey(parsed);
248
+ if (!sceneKey) {
249
+ return { ok: false, text: 'Current group shortcut only works inside a group chat.' };
250
+ }
251
+
252
+ const existing = sceneRegistry.get(sceneKey);
253
+ if (!existing) {
254
+ return { ok: false, text: `Scene not found: ${sceneKey}` };
255
+ }
256
+
257
+ if (command.kind === 'revoke') {
258
+ sceneRegistry.delete(sceneKey);
259
+ return { ok: true, text: `Revoked scene ${sceneKey}.` };
260
+ }
261
+
262
+ if (command.kind === 'bind') {
263
+ sceneRegistry.set(sceneKey, {
264
+ ...existing,
265
+ agentId: command.agentId,
266
+ lastSeenAt: now(),
267
+ });
268
+ return { ok: true, text: `Bound ${sceneKey} to agent ${command.agentId}.` };
269
+ }
270
+
271
+ if (command.kind === 'mode') {
272
+ if (existing.kind !== 'group') {
273
+ return { ok: false, text: `Scene ${sceneKey} is not a group scene.` };
274
+ }
275
+ sceneRegistry.set(sceneKey, {
276
+ ...existing,
277
+ groupReplyMode: command.mode,
278
+ lastSeenAt: now(),
279
+ });
280
+ return { ok: true, text: `Set ${sceneKey} reply mode to ${command.mode}.` };
281
+ }
282
+
283
+ const fallbackAgentId =
284
+ existing.kind === 'group'
285
+ ? defaultPublicAgentId
286
+ : parsed.isAdmin
287
+ ? defaultAdminAgentId
288
+ : defaultPublicAgentId;
289
+ const next = applySceneStatus(existing, command.kind === 'allow' ? 'allowed' : 'denied');
290
+ sceneRegistry.set(sceneKey, {
291
+ ...next,
292
+ ...(command.kind === 'allow' ? { agentId: existing.agentId || fallbackAgentId } : {}),
293
+ lastSeenAt: now(),
294
+ });
295
+
296
+ return {
297
+ ok: true,
298
+ text: command.kind === 'allow' ? `Allowed scene ${sceneKey}.` : `Denied scene ${sceneKey}.`,
299
+ };
300
+ }