@xmoxmo/bncr 0.5.5 → 0.5.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.
- package/dist/index.js +1 -9
- package/index.ts +1 -9
- package/package.json +1 -1
- package/scripts/check-pack.mjs +0 -6
- package/scripts/selfcheck.mjs +0 -7
- package/src/channel.ts +114 -37
- package/src/core/accounts.ts +1 -6
- package/src/core/connection-reachability.ts +1 -5
- package/src/core/dead-letter-diagnostics.ts +4 -10
- package/src/core/outbox-entry-builders.ts +38 -15
- package/src/core/outbox-file-transfer-failure.ts +5 -0
- package/src/core/outbox-file-transfer-guards.ts +6 -0
- package/src/core/outbox-file-transfer-prep.ts +8 -5
- package/src/core/outbox-file-transfer-success.ts +5 -0
- package/src/core/outbox-queue.ts +1 -7
- package/src/core/outbox-summary.ts +15 -27
- package/src/core/outbox-text-push-prep.ts +1 -36
- package/src/core/permissions.ts +1 -9
- package/src/core/persisted-outbox-entry.ts +54 -11
- package/src/core/policy.ts +1 -11
- package/src/core/register-trace.ts +2 -5
- package/src/core/status-meta.ts +1 -9
- package/src/core/status.ts +1 -21
- package/src/core/targets.ts +52 -70
- package/src/core/types.ts +1 -17
- package/src/core/value-sanitize.ts +38 -1
- package/src/messaging/inbound/commands.ts +2 -24
- package/src/messaging/inbound/dispatch-prep.ts +1 -1
- package/src/messaging/inbound/gate.ts +2 -31
- package/src/messaging/inbound/group-history.ts +0 -75
- package/src/messaging/inbound/native-command.ts +7 -3
- package/src/messaging/inbound/parse.ts +1 -31
- package/src/messaging/inbound/scene-admin.ts +181 -5
- package/src/messaging/outbound/diagnostics.ts +1 -6
- package/src/messaging/outbound/durable-queue-adapter.ts +17 -11
- package/src/messaging/outbound/marker-parser.ts +188 -0
- package/src/messaging/outbound/media-dedupe.ts +2 -6
- package/src/messaging/outbound/media.ts +6 -20
- package/src/messaging/outbound/normalize-outbound-send.ts +327 -0
- package/src/messaging/outbound/queue-selectors.ts +1 -5
- package/src/messaging/outbound/reply-enqueue-media.ts +4 -0
- package/src/messaging/outbound/reply-enqueue.ts +130 -57
- package/src/messaging/outbound/session-route.ts +6 -11
- package/src/messaging/outbound/target-resolver.ts +1 -6
- package/src/openclaw/routing-runtime.ts +0 -11
- package/src/openclaw/sdk-helpers.ts +0 -2
- package/src/plugin/bridge-file-transfer-push-facade.ts +8 -6
- package/src/plugin/bridge-media-facade.ts +1 -1
- package/src/plugin/channel-components.ts +0 -13
- package/src/plugin/channel-plugin-surface-group.ts +57 -40
- package/src/plugin/channel-runtime-builders-delivery.ts +3 -0
- package/src/plugin/channel-runtime-types.ts +10 -24
- package/src/plugin/channel-send.ts +241 -154
- package/src/plugin/channel-utils.ts +8 -29
- package/src/plugin/connection-handlers-helpers.ts +0 -15
- package/src/plugin/connection-handlers.ts +6 -5
- package/src/plugin/file-inbound-abort.ts +5 -0
- package/src/plugin/file-inbound-chunk.ts +5 -0
- package/src/plugin/file-inbound-complete.ts +5 -0
- package/src/plugin/file-transfer-orchestrator.ts +105 -3
- package/src/plugin/file-transfer-setup.ts +1 -1
- package/src/plugin/media-dedupe-runtime.ts +1 -6
- package/src/plugin/media-orchestrators-runtime-group.ts +2 -0
- package/src/plugin/message-send.ts +7 -8
- package/src/plugin/outbound.ts +6 -22
- package/src/plugin/outbox-drain-loop.ts +2 -2
- package/src/plugin/outbox-file-push-flow.ts +7 -7
- package/src/plugin/outbox-text-push-flow.ts +1 -1
- package/src/plugin/target-runtime.ts +1 -6
- package/src/runtime/outbound-ack-timeout.ts +1 -8
- package/src/runtime/outbox-transitions.ts +1 -4
- package/src/runtime/status-worker.ts +1 -9
- package/src/messaging/outbound/build-send-action.ts +0 -130
- package/src/messaging/outbound/durable-message-adapter.ts +0 -121
- package/src/messaging/outbound/send-params.ts +0 -81
- package/src/messaging/outbound/send.ts +0 -121
- package/src/openclaw/ingress-runtime.ts +0 -25
- package/src/plugin/channel-send-runtime-group.ts +0 -37
|
@@ -2,39 +2,9 @@ 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 { asBoolean, asString, asStringArray } from '../../core/value-sanitize.ts';
|
|
5
6
|
import type { BncrInboundMediaItem, BncrInboundParamsInput } from './contracts.ts';
|
|
6
7
|
|
|
7
|
-
function asString(v: unknown, fallback = ''): string {
|
|
8
|
-
if (typeof v === 'string') return v;
|
|
9
|
-
if (v == null) return fallback;
|
|
10
|
-
return String(v);
|
|
11
|
-
}
|
|
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
8
|
function asInboundMediaItems(params: BncrInboundParamsInput): BncrInboundMediaItem[] {
|
|
39
9
|
const normalized: BncrInboundMediaItem[] = [];
|
|
40
10
|
const rawList = Array.isArray(params?.mediaList) ? params.mediaList : [];
|
|
@@ -13,16 +13,22 @@ export type BncrSceneAdminCommand =
|
|
|
13
13
|
| { kind: 'bind'; sceneKey?: string; agentId: string }
|
|
14
14
|
| { kind: 'mode-help' }
|
|
15
15
|
| { kind: 'mode-get'; sceneKey?: string }
|
|
16
|
-
| { kind: 'mode'; sceneKey: string; mode:
|
|
16
|
+
| { kind: 'mode'; sceneKey: string; mode: string }
|
|
17
17
|
| { kind: 'list'; scope: 'pending' | 'scenes'; filters?: string[] }
|
|
18
18
|
| { kind: 'history-limit-get'; sceneKey?: string }
|
|
19
|
-
| { kind: 'history-limit-set'; sceneKey: string; limit: number }
|
|
19
|
+
| { kind: 'history-limit-set'; sceneKey: string; limit: number | 'clear' }
|
|
20
20
|
| { kind: 'history-force-get'; sceneKey?: string }
|
|
21
|
-
| { kind: 'history-force-set'; sceneKey: string; enabled: boolean }
|
|
22
|
-
| { kind: 'history-help' }
|
|
21
|
+
| { kind: 'history-force-set'; sceneKey: string; enabled: boolean | 'clear' }
|
|
22
|
+
| { kind: 'history-help' }
|
|
23
|
+
| { kind: 'download-media-get'; sceneKey?: string }
|
|
24
|
+
| { kind: 'download-media-set'; sceneKey: string; enabled: boolean | undefined }
|
|
25
|
+
| { kind: 'download-media-global-get' }
|
|
26
|
+
| { kind: 'download-media-global-set'; enabled: boolean };
|
|
23
27
|
|
|
24
28
|
const GROUP_REPLY_MODES = new Set<BncrGroupReplyMode>(['admin', 'mention', 'hybrid', 'all']);
|
|
25
29
|
|
|
30
|
+
const GLOBAL_SCENE_KEY = '__global__';
|
|
31
|
+
|
|
26
32
|
const MODE_HELP_TEXT = [
|
|
27
33
|
'💬 Bncr Group Reply Mode Configuration',
|
|
28
34
|
'',
|
|
@@ -122,6 +128,13 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
|
|
|
122
128
|
return { matched: true, valid: true, command: { kind: 'mode-get' } };
|
|
123
129
|
}
|
|
124
130
|
if (args.length === 1) {
|
|
131
|
+
if (args[0] === 'clear') {
|
|
132
|
+
return {
|
|
133
|
+
matched: true,
|
|
134
|
+
valid: true,
|
|
135
|
+
command: { kind: 'mode', sceneKey: '', mode: 'clear' },
|
|
136
|
+
};
|
|
137
|
+
}
|
|
125
138
|
if (args[0] === 'help') {
|
|
126
139
|
return {
|
|
127
140
|
matched: true,
|
|
@@ -142,6 +155,13 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
|
|
|
142
155
|
command: { kind: 'mode-get', sceneKey: args[0] },
|
|
143
156
|
};
|
|
144
157
|
}
|
|
158
|
+
if (args[0] === 'clear' && args[1]) {
|
|
159
|
+
return {
|
|
160
|
+
matched: true,
|
|
161
|
+
valid: true,
|
|
162
|
+
command: { kind: 'mode', sceneKey: args[1], mode: 'clear' },
|
|
163
|
+
};
|
|
164
|
+
}
|
|
145
165
|
if (GROUP_REPLY_MODES.has(args[0] as BncrGroupReplyMode) && args[1]) {
|
|
146
166
|
return {
|
|
147
167
|
matched: true,
|
|
@@ -181,6 +201,13 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
|
|
|
181
201
|
return { matched: true, valid: true, command: { kind: 'history-limit-get' } };
|
|
182
202
|
}
|
|
183
203
|
if (args.length === 1) {
|
|
204
|
+
if (args[0] === 'clear') {
|
|
205
|
+
return {
|
|
206
|
+
matched: true,
|
|
207
|
+
valid: true,
|
|
208
|
+
command: { kind: 'history-limit-set', sceneKey: '', limit: 'clear' },
|
|
209
|
+
};
|
|
210
|
+
}
|
|
184
211
|
const num = parseInt(args[0], 10);
|
|
185
212
|
if (Number.isFinite(num)) {
|
|
186
213
|
return {
|
|
@@ -196,6 +223,13 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
|
|
|
196
223
|
};
|
|
197
224
|
}
|
|
198
225
|
if (args.length === 2) {
|
|
226
|
+
if (args[0] === 'clear') {
|
|
227
|
+
return {
|
|
228
|
+
matched: true,
|
|
229
|
+
valid: true,
|
|
230
|
+
command: { kind: 'history-limit-set', sceneKey: args[1], limit: 'clear' },
|
|
231
|
+
};
|
|
232
|
+
}
|
|
199
233
|
const num = parseInt(args[0], 10);
|
|
200
234
|
if (Number.isFinite(num)) {
|
|
201
235
|
return {
|
|
@@ -214,6 +248,20 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
|
|
|
214
248
|
if (args.length === 0) {
|
|
215
249
|
return { matched: true, valid: true, command: { kind: 'history-force-get' } };
|
|
216
250
|
}
|
|
251
|
+
if (args[0] === 'clear') {
|
|
252
|
+
if (args.length === 1) {
|
|
253
|
+
return {
|
|
254
|
+
matched: true,
|
|
255
|
+
valid: true,
|
|
256
|
+
command: { kind: 'history-force-set', sceneKey: '', enabled: 'clear' },
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
matched: true,
|
|
261
|
+
valid: true,
|
|
262
|
+
command: { kind: 'history-force-set', sceneKey: args[1], enabled: 'clear' },
|
|
263
|
+
};
|
|
264
|
+
}
|
|
217
265
|
if (args[0] === 'on' || args[0] === 'off') {
|
|
218
266
|
if (args.length === 1) {
|
|
219
267
|
return {
|
|
@@ -233,6 +281,50 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
|
|
|
233
281
|
valid: true,
|
|
234
282
|
command: { kind: 'history-force-get', sceneKey: args[0] },
|
|
235
283
|
};
|
|
284
|
+
case 'download-media':
|
|
285
|
+
if (args.length === 0) {
|
|
286
|
+
return { matched: true, valid: true, command: { kind: 'download-media-get' } };
|
|
287
|
+
}
|
|
288
|
+
if (args[0] === 'default') {
|
|
289
|
+
if (args[1] === 'on' || args[1] === 'off') {
|
|
290
|
+
return {
|
|
291
|
+
matched: true,
|
|
292
|
+
valid: true,
|
|
293
|
+
command: { kind: 'download-media-global-set', enabled: args[1] === 'on' },
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
return { matched: true, valid: true, command: { kind: 'download-media-global-get' } };
|
|
297
|
+
}
|
|
298
|
+
if (args[0] === 'clear') {
|
|
299
|
+
return {
|
|
300
|
+
matched: true,
|
|
301
|
+
valid: true,
|
|
302
|
+
command: {
|
|
303
|
+
kind: 'download-media-set',
|
|
304
|
+
sceneKey: args[1] || '',
|
|
305
|
+
enabled: undefined,
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
if (args[0] === 'on' || args[0] === 'off') {
|
|
310
|
+
if (args.length === 1) {
|
|
311
|
+
return {
|
|
312
|
+
matched: true,
|
|
313
|
+
valid: true,
|
|
314
|
+
command: { kind: 'download-media-set', sceneKey: '', enabled: args[0] === 'on' },
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
matched: true,
|
|
319
|
+
valid: true,
|
|
320
|
+
command: { kind: 'download-media-set', sceneKey: args[1], enabled: args[0] === 'on' },
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
matched: true,
|
|
325
|
+
valid: true,
|
|
326
|
+
command: { kind: 'download-media-get', sceneKey: args[0] },
|
|
327
|
+
};
|
|
236
328
|
case 'history-help':
|
|
237
329
|
return { matched: true, valid: true, command: { kind: 'history-help' } };
|
|
238
330
|
default:
|
|
@@ -399,6 +491,11 @@ export function executeSceneAdminCommand(args: {
|
|
|
399
491
|
}
|
|
400
492
|
const sExisting = sceneRegistry.get(sSceneKey);
|
|
401
493
|
if (!sExisting) return { ok: false, text: `Scene not found: ${sSceneKey}` };
|
|
494
|
+
if (command.limit === 'clear') {
|
|
495
|
+
const { historyLimit: _, ...rest } = sExisting;
|
|
496
|
+
sceneRegistry.set(sSceneKey, { ...rest, lastSeenAt: now() });
|
|
497
|
+
return { ok: true, text: `Cleared ${sSceneKey} history limit. Will use default (50).` };
|
|
498
|
+
}
|
|
402
499
|
const rawLimit = command.limit;
|
|
403
500
|
if (!Number.isFinite(rawLimit) || Number.isNaN(rawLimit)) {
|
|
404
501
|
return { ok: false, text: 'Invalid number.' };
|
|
@@ -443,6 +540,11 @@ export function executeSceneAdminCommand(args: {
|
|
|
443
540
|
}
|
|
444
541
|
const sExisting = sceneRegistry.get(sSceneKey);
|
|
445
542
|
if (!sExisting) return { ok: false, text: `Scene not found: ${sSceneKey}` };
|
|
543
|
+
if (command.enabled === 'clear') {
|
|
544
|
+
const { historyForce: _, ...rest } = sExisting;
|
|
545
|
+
sceneRegistry.set(sSceneKey, { ...rest, lastSeenAt: now() });
|
|
546
|
+
return { ok: true, text: `Cleared ${sSceneKey} history auto flush. Will use default (on).` };
|
|
547
|
+
}
|
|
446
548
|
sceneRegistry.set(sSceneKey, {
|
|
447
549
|
...sExisting,
|
|
448
550
|
historyForce: command.enabled,
|
|
@@ -454,6 +556,75 @@ export function executeSceneAdminCommand(args: {
|
|
|
454
556
|
};
|
|
455
557
|
}
|
|
456
558
|
|
|
559
|
+
if (command.kind === 'download-media-global-get') {
|
|
560
|
+
const g = sceneRegistry.get(GLOBAL_SCENE_KEY);
|
|
561
|
+
const enabled = g?.downloadMedia === true;
|
|
562
|
+
return { ok: true, text: `Global default download remote media is ${enabled ? 'on' : 'off'}.` };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (command.kind === 'download-media-global-set') {
|
|
566
|
+
const g = sceneRegistry.get(GLOBAL_SCENE_KEY);
|
|
567
|
+
sceneRegistry.set(GLOBAL_SCENE_KEY, {
|
|
568
|
+
sceneKey: GLOBAL_SCENE_KEY,
|
|
569
|
+
kind: 'group',
|
|
570
|
+
status: 'allowed',
|
|
571
|
+
...(g || { platform: 'bncr' }),
|
|
572
|
+
downloadMedia: command.enabled,
|
|
573
|
+
lastSeenAt: now(),
|
|
574
|
+
});
|
|
575
|
+
return {
|
|
576
|
+
ok: true,
|
|
577
|
+
text: `Global default download remote media set to ${command.enabled ? 'on' : 'off'}.`,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (command.kind === 'download-media-get') {
|
|
582
|
+
const qSceneKey = command.sceneKey || resolveCurrentGroupSceneKey(parsed);
|
|
583
|
+
if (!qSceneKey) {
|
|
584
|
+
return {
|
|
585
|
+
ok: true,
|
|
586
|
+
text: `Global default download remote media is ${sceneRegistry.get(GLOBAL_SCENE_KEY)?.downloadMedia === true ? 'on' : 'off'}.`,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
const s = sceneRegistry.get(qSceneKey);
|
|
590
|
+
if (!s) return { ok: true, text: 'Default download remote media is off.' };
|
|
591
|
+
const enabled = s.downloadMedia === true;
|
|
592
|
+
return {
|
|
593
|
+
ok: true,
|
|
594
|
+
text: `Current ${qSceneKey} download remote media is ${enabled ? 'on' : 'off'}.`,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (command.kind === 'download-media-set') {
|
|
599
|
+
const sSceneKey = command.sceneKey || resolveCurrentGroupSceneKey(parsed);
|
|
600
|
+
if (!sSceneKey) {
|
|
601
|
+
return { ok: false, text: 'Current group shortcut only works inside a group chat.' };
|
|
602
|
+
}
|
|
603
|
+
const sExisting = sceneRegistry.get(sSceneKey);
|
|
604
|
+
if (command.enabled === undefined) {
|
|
605
|
+
// Clear per-group setting
|
|
606
|
+
if (sExisting) {
|
|
607
|
+
const { downloadMedia: _, ...rest } = sExisting;
|
|
608
|
+
sceneRegistry.set(sSceneKey, { ...rest, lastSeenAt: now() });
|
|
609
|
+
return {
|
|
610
|
+
ok: true,
|
|
611
|
+
text: `Cleared ${sSceneKey} download remote media. Will use global default.`,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
return { ok: true, text: `No per-group config to clear for ${sSceneKey}.` };
|
|
615
|
+
}
|
|
616
|
+
if (!sExisting) return { ok: false, text: `Scene not found: ${sSceneKey}` };
|
|
617
|
+
sceneRegistry.set(sSceneKey, {
|
|
618
|
+
...sExisting,
|
|
619
|
+
downloadMedia: command.enabled,
|
|
620
|
+
lastSeenAt: now(),
|
|
621
|
+
});
|
|
622
|
+
return {
|
|
623
|
+
ok: true,
|
|
624
|
+
text: `Set ${sSceneKey} download remote media to ${command.enabled ? 'on' : 'off'}.`,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
457
628
|
const sceneKey = command.sceneKey || resolveCurrentGroupSceneKey(parsed);
|
|
458
629
|
if (!sceneKey) {
|
|
459
630
|
return { ok: false, text: 'Current group shortcut only works inside a group chat.' };
|
|
@@ -482,9 +653,14 @@ export function executeSceneAdminCommand(args: {
|
|
|
482
653
|
if (existing.kind !== 'group') {
|
|
483
654
|
return { ok: false, text: `Scene ${sceneKey} is not a group scene.` };
|
|
484
655
|
}
|
|
656
|
+
if (command.mode === 'clear') {
|
|
657
|
+
const { groupReplyMode: _, ...rest } = existing;
|
|
658
|
+
sceneRegistry.set(sceneKey, { ...rest, lastSeenAt: now() });
|
|
659
|
+
return { ok: true, text: `Cleared ${sceneKey} reply mode. Will use default (admin).` };
|
|
660
|
+
}
|
|
485
661
|
sceneRegistry.set(sceneKey, {
|
|
486
662
|
...existing,
|
|
487
|
-
groupReplyMode: command.mode,
|
|
663
|
+
groupReplyMode: command.mode as BncrGroupReplyMode,
|
|
488
664
|
lastSeenAt: now(),
|
|
489
665
|
});
|
|
490
666
|
return { ok: true, text: `Set ${sceneKey} reply mode to ${command.mode}.` };
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
BncrOutboxQueueDiagnostics,
|
|
6
6
|
OutboxEntry,
|
|
7
7
|
} from '../../core/types.ts';
|
|
8
|
+
import { finiteNumberOrNull } from '../../core/value-sanitize.ts';
|
|
8
9
|
|
|
9
10
|
export {
|
|
10
11
|
buildFlushDebugInfo,
|
|
@@ -42,12 +43,6 @@ type OutboxIncidentSummaryInput = {
|
|
|
42
43
|
nowMs?: number;
|
|
43
44
|
};
|
|
44
45
|
|
|
45
|
-
function finiteNumberOrNull(value: unknown): number | null {
|
|
46
|
-
if (value == null) return null;
|
|
47
|
-
const n = Number(value);
|
|
48
|
-
return Number.isFinite(n) ? n : null;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
46
|
function positiveAgeMs(nowMs: number, at: unknown): number | null {
|
|
52
47
|
const n = finiteNumberOrNull(at);
|
|
53
48
|
if (n === null || n < 0) return null;
|
|
@@ -8,7 +8,7 @@ export type BncrDurableQueuedReceipt = {
|
|
|
8
8
|
platformMessageId: string;
|
|
9
9
|
kind: 'text' | 'media' | 'voice' | 'unknown';
|
|
10
10
|
index: number;
|
|
11
|
-
threadId?: string;
|
|
11
|
+
threadId?: string | number | null;
|
|
12
12
|
replyToId?: string;
|
|
13
13
|
raw: {
|
|
14
14
|
channel: 'bncr';
|
|
@@ -20,7 +20,7 @@ export type BncrDurableQueuedReceipt = {
|
|
|
20
20
|
meta: BncrDurableQueuedReceiptMeta;
|
|
21
21
|
};
|
|
22
22
|
}>;
|
|
23
|
-
threadId?: string;
|
|
23
|
+
threadId?: string | number | null;
|
|
24
24
|
replyToId?: string;
|
|
25
25
|
sentAt: number;
|
|
26
26
|
raw: Array<{
|
|
@@ -66,8 +66,12 @@ export type BncrDurableQueuedResult = {
|
|
|
66
66
|
};
|
|
67
67
|
|
|
68
68
|
type OutboxPayload = OutboxEntry['payload'];
|
|
69
|
-
type
|
|
70
|
-
type
|
|
69
|
+
type OutboxMessagePayload = {
|
|
70
|
+
type?: unknown;
|
|
71
|
+
transferMode?: string;
|
|
72
|
+
asVoice?: boolean;
|
|
73
|
+
audioAsVoice?: boolean;
|
|
74
|
+
};
|
|
71
75
|
|
|
72
76
|
function getOutboxPayload(entry: OutboxEntry): OutboxPayload {
|
|
73
77
|
return entry.payload;
|
|
@@ -76,7 +80,7 @@ function getOutboxPayload(entry: OutboxEntry): OutboxPayload {
|
|
|
76
80
|
export function buildBncrDurableQueuedResult(args: {
|
|
77
81
|
entry: OutboxEntry;
|
|
78
82
|
index?: number;
|
|
79
|
-
threadId?: string;
|
|
83
|
+
threadId?: string | number | null;
|
|
80
84
|
replyToId?: string;
|
|
81
85
|
sentAt?: number;
|
|
82
86
|
}): BncrDurableQueuedResult {
|
|
@@ -145,20 +149,22 @@ function extractPayloadType(entry: OutboxEntry): string | undefined {
|
|
|
145
149
|
|
|
146
150
|
function extractReplyToId(entry: OutboxEntry): string | undefined {
|
|
147
151
|
const payload = getOutboxPayload(entry);
|
|
148
|
-
const
|
|
149
|
-
|
|
152
|
+
const message =
|
|
153
|
+
payload?.message && typeof payload.message === 'object' && !Array.isArray(payload.message)
|
|
154
|
+
? (payload.message as Record<string, unknown>)
|
|
155
|
+
: null;
|
|
156
|
+
const replyToId = payload?.replyToId ?? (message?.replyToId as string | undefined);
|
|
150
157
|
return typeof replyToId === 'string' ? replyToId : undefined;
|
|
151
158
|
}
|
|
152
159
|
|
|
153
160
|
function inferReceiptKind(entry: OutboxEntry): 'text' | 'media' | 'voice' | 'unknown' {
|
|
154
161
|
const payload = getOutboxPayload(entry);
|
|
155
|
-
const meta = payload?._meta as OutboxMeta | undefined;
|
|
156
162
|
const message =
|
|
157
|
-
payload
|
|
163
|
+
payload?.message && typeof payload.message === 'object'
|
|
158
164
|
? (payload.message as OutboxMessagePayload)
|
|
159
165
|
: null;
|
|
160
|
-
if (
|
|
161
|
-
if (
|
|
166
|
+
if (message?.transferMode === 'media') {
|
|
167
|
+
if (message?.asVoice === true || message?.audioAsVoice === true) return 'voice';
|
|
162
168
|
return 'media';
|
|
163
169
|
}
|
|
164
170
|
if (message?.type === 'text') return 'text';
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [BncrParam:JSON] marker parser.
|
|
3
|
+
*
|
|
4
|
+
* Extracts embedded JSON control parameters from agent text, removes the
|
|
5
|
+
* marker from the visible message, and returns the parsed key-value pairs.
|
|
6
|
+
*
|
|
7
|
+
* Format: [BncrParam:{"key":"val","asVoice":true}]
|
|
8
|
+
*
|
|
9
|
+
* - Complete marker whose JSON parses successfully → stripped, params merged.
|
|
10
|
+
* - Complete marker whose JSON fails to parse → left in text (not stripped)
|
|
11
|
+
* so the user can see and fix the bad payload.
|
|
12
|
+
* - Incomplete marker (no closing ]) → left in text, does NOT block later
|
|
13
|
+
* complete markers.
|
|
14
|
+
* - Multiple valid markers → all stripped, params merge (later same-key wins).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const PREFIX = '[BncrParam:';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Locate the closing `]` of a complete marker starting at `prefixIdx`.
|
|
21
|
+
* Returns the index of the closing `]`, or -1 if incomplete.
|
|
22
|
+
*/
|
|
23
|
+
function findMarkerEnd(text: string, prefixIdx: number): number {
|
|
24
|
+
const start = prefixIdx + PREFIX.length;
|
|
25
|
+
if (start >= text.length) return -1;
|
|
26
|
+
|
|
27
|
+
// Scan character-by-character tracking brace/bracket depth so that
|
|
28
|
+
// ] inside JSON values (e.g. CDATA / lyric tags) is not mistaken for
|
|
29
|
+
// the closing marker bracket.
|
|
30
|
+
let depth = 0;
|
|
31
|
+
let inString = false;
|
|
32
|
+
let esc = false;
|
|
33
|
+
let started = false;
|
|
34
|
+
|
|
35
|
+
for (let i = start; i < text.length; i++) {
|
|
36
|
+
const ch = text[i];
|
|
37
|
+
|
|
38
|
+
if (esc) {
|
|
39
|
+
esc = false;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (ch === '\\' && inString) {
|
|
43
|
+
esc = true;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (ch === '"') {
|
|
47
|
+
inString = !inString;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!inString) {
|
|
52
|
+
if (!started) {
|
|
53
|
+
if (ch === '{' || ch === '[') {
|
|
54
|
+
started = true;
|
|
55
|
+
depth = 1;
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (ch === '{' || ch === '[') {
|
|
61
|
+
depth++;
|
|
62
|
+
} else if (ch === '}' || ch === ']') {
|
|
63
|
+
depth--;
|
|
64
|
+
if (depth === 0) {
|
|
65
|
+
// JSON content complete - expect ] to close the marker
|
|
66
|
+
if (i + 1 < text.length && text[i + 1] === ']') {
|
|
67
|
+
return i + 1;
|
|
68
|
+
}
|
|
69
|
+
return -1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return -1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function parseBncrMarker(text: string): {
|
|
79
|
+
cleanText: string;
|
|
80
|
+
params: Record<string, unknown>;
|
|
81
|
+
} {
|
|
82
|
+
const params: Record<string, unknown> = {};
|
|
83
|
+
let result = text;
|
|
84
|
+
let searchFrom = 0;
|
|
85
|
+
|
|
86
|
+
while (true) {
|
|
87
|
+
const idx = result.indexOf(PREFIX, searchFrom);
|
|
88
|
+
if (idx === -1) break;
|
|
89
|
+
|
|
90
|
+
const end = findMarkerEnd(result, idx);
|
|
91
|
+
if (end === -1) {
|
|
92
|
+
// Incomplete marker (missing ]): leave in place,
|
|
93
|
+
// keep looking after this PREFIX so later valid markers are not blocked.
|
|
94
|
+
searchFrom = idx + PREFIX.length;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const rawJson = result.slice(idx + PREFIX.length, end);
|
|
99
|
+
let parsedOk = false;
|
|
100
|
+
try {
|
|
101
|
+
const parsed = JSON.parse(rawJson);
|
|
102
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
103
|
+
Object.assign(params, parsed as Record<string, unknown>);
|
|
104
|
+
parsedOk = true;
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// JSON parse failed — do NOT strip marker so the user can debug it
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (parsedOk) {
|
|
111
|
+
// Strip the complete valid marker
|
|
112
|
+
result = result.slice(0, idx) + result.slice(end + 1);
|
|
113
|
+
searchFrom = idx;
|
|
114
|
+
} else {
|
|
115
|
+
// Leave the invalid marker in place, continue after it
|
|
116
|
+
searchFrom = end + 1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Only trim whitespace when markers were actually stripped (result changed).
|
|
121
|
+
// Without markers the original text is returned as-is for maximum fidelity.
|
|
122
|
+
const cleanText = result !== text ? result.replace(/ {2,}/g, ' ').trim() : result;
|
|
123
|
+
return { cleanText, params };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Media types that the pipeline internally routes as media sends.
|
|
128
|
+
* Only these types are consumed (stripped) from extra.
|
|
129
|
+
* Non-media types like "appmsg" remain in extra for downstream passthrough.
|
|
130
|
+
*/
|
|
131
|
+
const MEDIA_CONSUMPTION_TYPES = new Set(['file', 'image', 'video', 'audio', 'voice']);
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Known fields that the bncr plugin unconditionally consumes during outbound
|
|
135
|
+
* processing. These are stripped from extra at entry and set as independent
|
|
136
|
+
* pipeline params. `type` is NOT in this set — it is consumed conditionally
|
|
137
|
+
* based on whether its value is a recognized media type.
|
|
138
|
+
*/
|
|
139
|
+
const CONSUMPTION_FIELDS_NO_TYPE = new Set([
|
|
140
|
+
'ismedia',
|
|
141
|
+
'asVoice',
|
|
142
|
+
'audioAsVoice',
|
|
143
|
+
'kind',
|
|
144
|
+
'replyToId',
|
|
145
|
+
'downloadMedia',
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
export function extractConsumptionFields(extra: Record<string, unknown> | undefined): {
|
|
149
|
+
consumed: Partial<{
|
|
150
|
+
asVoice: boolean;
|
|
151
|
+
audioAsVoice: boolean;
|
|
152
|
+
type: string;
|
|
153
|
+
kind: string;
|
|
154
|
+
replyToId: string;
|
|
155
|
+
downloadMedia: boolean;
|
|
156
|
+
ismedia: boolean;
|
|
157
|
+
}>;
|
|
158
|
+
remaining: Record<string, unknown>;
|
|
159
|
+
} {
|
|
160
|
+
const consumed: Record<string, unknown> = {};
|
|
161
|
+
const remaining: Record<string, unknown> = {};
|
|
162
|
+
|
|
163
|
+
if (!extra) return { consumed: {}, remaining: {} };
|
|
164
|
+
|
|
165
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
166
|
+
// type is only consumed when its value is a recognized media type
|
|
167
|
+
// (file/image/video/audio/voice). Non-media types like "appmsg"
|
|
168
|
+
// stay in remaining for downstream passthrough.
|
|
169
|
+
if (key === 'type' && typeof value === 'string') {
|
|
170
|
+
if (MEDIA_CONSUMPTION_TYPES.has(value.trim().toLowerCase())) {
|
|
171
|
+
consumed[key] = value;
|
|
172
|
+
} else {
|
|
173
|
+
remaining[key] = value;
|
|
174
|
+
}
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (CONSUMPTION_FIELDS_NO_TYPE.has(key)) {
|
|
178
|
+
consumed[key] = value;
|
|
179
|
+
} else {
|
|
180
|
+
remaining[key] = value;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
consumed: consumed as Record<string, unknown>,
|
|
186
|
+
remaining,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { asString } from '../../core/value-sanitize.ts';
|
|
2
|
+
|
|
1
3
|
export type MediaDedupeCacheEntry = {
|
|
2
4
|
mediaUrl: string;
|
|
3
5
|
text: string;
|
|
@@ -5,12 +7,6 @@ export type MediaDedupeCacheEntry = {
|
|
|
5
7
|
createdAt: number;
|
|
6
8
|
};
|
|
7
9
|
|
|
8
|
-
function asString(v: unknown, fallback = ''): string {
|
|
9
|
-
if (typeof v === 'string') return v;
|
|
10
|
-
if (v == null) return fallback;
|
|
11
|
-
return String(v);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
10
|
export function normalizeReplyToId(value: unknown): string {
|
|
15
11
|
return asString(value || '').trim();
|
|
16
12
|
}
|
|
@@ -1,18 +1,8 @@
|
|
|
1
|
+
import { asString } from '../../core/value-sanitize.ts';
|
|
1
2
|
import { normalizeOutboundReplyToId } from './reply-target-policy.ts';
|
|
2
3
|
|
|
3
4
|
type BncrOutboundMessageType = 'text' | 'image' | 'video' | 'voice' | 'audio' | 'file';
|
|
4
5
|
|
|
5
|
-
function asString(v: unknown, fallback = ''): string {
|
|
6
|
-
if (typeof v === 'string') return v;
|
|
7
|
-
if (v == null) return fallback;
|
|
8
|
-
return String(v);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function isAudioMimeType(mimeType?: string): boolean {
|
|
12
|
-
const mt = asString(mimeType || '').toLowerCase();
|
|
13
|
-
return mt.startsWith('audio/');
|
|
14
|
-
}
|
|
15
|
-
|
|
16
6
|
function isBncrOutboundMessageType(value: string): value is BncrOutboundMessageType {
|
|
17
7
|
return (
|
|
18
8
|
value === 'text' ||
|
|
@@ -43,11 +33,7 @@ export function resolveBncrOutboundMessageType(params: {
|
|
|
43
33
|
const isStandard = isBncrOutboundMessageType(hinted);
|
|
44
34
|
|
|
45
35
|
if (hasPayload && major === 'text' && (hinted === 'text' || !isStandard)) return 'file';
|
|
46
|
-
if (hinted === 'voice')
|
|
47
|
-
if (isAudioMimeType(mt)) return 'voice';
|
|
48
|
-
if (isMimeMajorOutboundMessageType(major)) return major;
|
|
49
|
-
return 'file';
|
|
50
|
-
}
|
|
36
|
+
if (hinted === 'voice') return 'voice';
|
|
51
37
|
if (isStandard) return hinted;
|
|
52
38
|
if (isMimeMajorOutboundMessageType(major)) return major;
|
|
53
39
|
return 'file';
|
|
@@ -61,7 +47,7 @@ export function buildBncrMediaOutboundFrame(params: {
|
|
|
61
47
|
mode: 'base64' | 'chunk';
|
|
62
48
|
mimeType?: string;
|
|
63
49
|
fileName?: string;
|
|
64
|
-
|
|
50
|
+
base64?: string;
|
|
65
51
|
path?: string;
|
|
66
52
|
};
|
|
67
53
|
mediaUrl: string;
|
|
@@ -87,17 +73,17 @@ export function buildBncrMediaOutboundFrame(params: {
|
|
|
87
73
|
type: resolveBncrOutboundMessageType({
|
|
88
74
|
mimeType: params.media.mimeType,
|
|
89
75
|
fileName: params.media.fileName,
|
|
90
|
-
hasPayload: !!(params.media.path || params.media.
|
|
76
|
+
hasPayload: !!(params.media.path || params.media.base64),
|
|
91
77
|
hintedType: params.hintedType,
|
|
92
78
|
}),
|
|
93
79
|
kind: params.kind,
|
|
94
80
|
mimeType: params.media.mimeType || '',
|
|
95
81
|
msg: params.mediaMsg,
|
|
96
82
|
path: params.media.path || params.mediaUrl,
|
|
97
|
-
base64: params.media.
|
|
83
|
+
base64: params.media.base64 || '',
|
|
98
84
|
fileName: params.fileName,
|
|
99
85
|
transferMode: params.media.mode,
|
|
100
|
-
...(params.extra ? {
|
|
86
|
+
...(params.extra ? { ...params.extra } : {}),
|
|
101
87
|
},
|
|
102
88
|
ts: params.now,
|
|
103
89
|
};
|