@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.
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/channel.ts +41 -2
- package/src/core/targets.ts +106 -17
- package/src/messaging/inbound/commands.ts +421 -51
- package/src/messaging/inbound/context-facts.ts +126 -14
- package/src/messaging/inbound/contracts.ts +24 -0
- package/src/messaging/inbound/dispatch-prep.ts +214 -39
- package/src/messaging/inbound/dispatch.ts +71 -5
- package/src/messaging/inbound/gate.ts +56 -86
- package/src/messaging/inbound/group-history.ts +189 -0
- package/src/messaging/inbound/native-command-runtime.ts +77 -61
- package/src/messaging/inbound/native-command.ts +192 -8
- package/src/messaging/inbound/parse.ts +113 -8
- package/src/messaging/inbound/reply-dispatch-serial.ts +62 -0
- package/src/messaging/inbound/reply-dispatch.ts +252 -77
- package/src/messaging/inbound/scene-admin.ts +300 -0
- package/src/messaging/inbound/session-label.ts +122 -13
- package/src/messaging/inbound/session-meta-task.ts +17 -0
- package/src/messaging/inbound/turn-context.ts +184 -71
- package/src/openclaw/channel-runtime-contracts.ts +1 -0
- package/src/plugin/channel-components.ts +35 -1
- package/src/plugin/channel-inbound-helpers.ts +9 -2
- package/src/plugin/channel-runtime-builders-delivery.ts +24 -1
- package/src/plugin/channel-runtime-types.ts +42 -0
- package/src/plugin/file-inbound-init.ts +27 -12
- package/src/plugin/file-inbound-runtime.ts +2 -0
- package/src/plugin/inbound-acceptance.ts +82 -1
- package/src/plugin/inbound-handlers.ts +97 -2
- package/src/plugin/inbound-surface-handlers-group.ts +16 -0
- package/src/plugin/messaging.ts +22 -5
- package/src/plugin/scene-registry.ts +171 -0
- package/src/plugin/state-store.ts +133 -0
- package/src/plugin/state-transient-runtime-group.ts +5 -0
- 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 {
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
326
|
+
};
|
|
327
|
+
},
|
|
328
|
+
onFinalize: () => {
|
|
329
|
+
const inboundAt = Date.now();
|
|
330
|
+
setInboundActivity(resolution.accountId, inboundAt);
|
|
331
|
+
scheduleSave();
|
|
332
|
+
},
|
|
333
|
+
},
|
|
154
334
|
}),
|
|
155
|
-
|
|
156
|
-
|
|
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
|
+
}
|