@xmoxmo/bncr 0.5.6 → 0.5.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/dist/index.js +1 -9
- package/index.ts +1 -9
- package/package.json +1 -1
- package/scripts/check-pack.mjs +0 -8
- package/scripts/selfcheck.mjs +0 -10
- package/src/bootstrap/runtime-loader.ts +1 -1
- package/src/channel.ts +118 -171
- package/src/core/accounts.ts +1 -6
- package/src/core/connection-reachability.ts +2 -6
- package/src/core/dead-letter-diagnostics.ts +4 -10
- package/src/core/logging.ts +1 -1
- package/src/core/outbox-entry-builders.ts +78 -84
- 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 +10 -56
- package/src/core/permissions.ts +2 -10
- package/src/core/persisted-outbox-entry.ts +54 -11
- package/src/core/policy.ts +1 -11
- package/src/core/register-trace.ts +4 -7
- 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/parse.ts +2 -32
- package/src/messaging/inbound/scene-admin.ts +8 -8
- package/src/messaging/outbound/diagnostics-debug-builders.ts +0 -15
- package/src/messaging/outbound/diagnostics.ts +1 -7
- package/src/messaging/outbound/durable-queue-adapter.ts +17 -11
- package/src/messaging/outbound/marker-parser.ts +277 -28
- package/src/messaging/outbound/media-dedupe.ts +3 -10
- package/src/messaging/outbound/media.ts +1 -6
- package/src/messaging/outbound/normalize-outbound-send.ts +329 -0
- package/src/messaging/outbound/queue-selectors.ts +1 -5
- package/src/messaging/outbound/reply-enqueue-media.ts +1 -184
- package/src/messaging/outbound/reply-enqueue.ts +313 -175
- package/src/messaging/outbound/session-route.ts +6 -11
- package/src/messaging/outbound/target-resolver.ts +1 -6
- package/src/openclaw/media-runtime.ts +1 -1
- package/src/openclaw/routing-runtime.ts +0 -11
- package/src/openclaw/sdk-helpers.ts +0 -2
- package/src/plugin/bridge-media-facade.ts +0 -4
- package/src/plugin/bridge-outbox-facade.ts +0 -1
- package/src/plugin/channel-components.ts +0 -13
- package/src/plugin/channel-plugin-surface-group.ts +52 -65
- package/src/plugin/channel-runtime-builders-delivery.ts +3 -362
- package/src/plugin/channel-runtime-builders-status.ts +0 -160
- package/src/plugin/channel-runtime-builders.ts +3 -17
- package/src/plugin/channel-runtime-constants.ts +1 -1
- package/src/plugin/channel-runtime-types.ts +2 -24
- package/src/plugin/channel-send.ts +206 -221
- 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 +39 -2
- package/src/plugin/media-dedupe-runtime.ts +1 -6
- package/src/plugin/media-orchestrators-runtime-group.ts +20 -149
- package/src/plugin/message-send.ts +7 -8
- package/src/plugin/messaging.ts +3 -3
- package/src/plugin/outbound.ts +6 -26
- package/src/plugin/outbox-drain-loop.ts +2 -2
- package/src/plugin/outbox-push.ts +1 -52
- package/src/plugin/outbox-unified-push-flow.ts +307 -0
- package/src/plugin/scene-registry.ts +1 -1
- package/src/plugin/target-runtime.ts +1 -6
- package/src/runtime/log-dedupe.ts +3 -3
- package/src/runtime/outbound-ack-timeout.ts +3 -12
- package/src/runtime/outbox-transitions.ts +1 -4
- package/src/runtime/status-snapshots.ts +2 -2
- package/src/runtime/status-worker.ts +3 -15
- package/src/core/outbox-text-push-prep.ts +0 -36
- 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 -114
- package/src/messaging/outbound/send.ts +0 -127
- package/src/openclaw/ingress-runtime.ts +0 -25
- package/src/plugin/bridge-file-transfer-push-facade.ts +0 -142
- package/src/plugin/channel-send-runtime-group.ts +0 -37
- package/src/plugin/outbox-file-push-flow.ts +0 -69
- package/src/plugin/outbox-text-push-flow.ts +0 -90
|
@@ -21,7 +21,7 @@ export type BncrSceneAdminCommand =
|
|
|
21
21
|
| { kind: 'history-force-set'; sceneKey: string; enabled: boolean | 'clear' }
|
|
22
22
|
| { kind: 'history-help' }
|
|
23
23
|
| { kind: 'download-media-get'; sceneKey?: string }
|
|
24
|
-
| { kind: 'download-media-set'; sceneKey: string; enabled: boolean }
|
|
24
|
+
| { kind: 'download-media-set'; sceneKey: string; enabled: boolean | undefined }
|
|
25
25
|
| { kind: 'download-media-global-get' }
|
|
26
26
|
| { kind: 'download-media-global-set'; enabled: boolean };
|
|
27
27
|
|
|
@@ -132,7 +132,7 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
|
|
|
132
132
|
return {
|
|
133
133
|
matched: true,
|
|
134
134
|
valid: true,
|
|
135
|
-
command: { kind: 'mode', sceneKey: '', mode: 'clear'
|
|
135
|
+
command: { kind: 'mode', sceneKey: '', mode: 'clear' },
|
|
136
136
|
};
|
|
137
137
|
}
|
|
138
138
|
if (args[0] === 'help') {
|
|
@@ -159,7 +159,7 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
|
|
|
159
159
|
return {
|
|
160
160
|
matched: true,
|
|
161
161
|
valid: true,
|
|
162
|
-
command: { kind: 'mode', sceneKey: args[1], mode: 'clear'
|
|
162
|
+
command: { kind: 'mode', sceneKey: args[1], mode: 'clear' },
|
|
163
163
|
};
|
|
164
164
|
}
|
|
165
165
|
if (GROUP_REPLY_MODES.has(args[0] as BncrGroupReplyMode) && args[1]) {
|
|
@@ -205,7 +205,7 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
|
|
|
205
205
|
return {
|
|
206
206
|
matched: true,
|
|
207
207
|
valid: true,
|
|
208
|
-
command: { kind: 'history-limit-set', sceneKey: '', limit: 'clear'
|
|
208
|
+
command: { kind: 'history-limit-set', sceneKey: '', limit: 'clear' },
|
|
209
209
|
};
|
|
210
210
|
}
|
|
211
211
|
const num = parseInt(args[0], 10);
|
|
@@ -227,7 +227,7 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
|
|
|
227
227
|
return {
|
|
228
228
|
matched: true,
|
|
229
229
|
valid: true,
|
|
230
|
-
command: { kind: 'history-limit-set', sceneKey: args[1], limit: 'clear'
|
|
230
|
+
command: { kind: 'history-limit-set', sceneKey: args[1], limit: 'clear' },
|
|
231
231
|
};
|
|
232
232
|
}
|
|
233
233
|
const num = parseInt(args[0], 10);
|
|
@@ -253,13 +253,13 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
|
|
|
253
253
|
return {
|
|
254
254
|
matched: true,
|
|
255
255
|
valid: true,
|
|
256
|
-
command: { kind: 'history-force-set', sceneKey: '', enabled: 'clear'
|
|
256
|
+
command: { kind: 'history-force-set', sceneKey: '', enabled: 'clear' },
|
|
257
257
|
};
|
|
258
258
|
}
|
|
259
259
|
return {
|
|
260
260
|
matched: true,
|
|
261
261
|
valid: true,
|
|
262
|
-
command: { kind: 'history-force-set', sceneKey: args[1], enabled: 'clear'
|
|
262
|
+
command: { kind: 'history-force-set', sceneKey: args[1], enabled: 'clear' },
|
|
263
263
|
};
|
|
264
264
|
}
|
|
265
265
|
if (args[0] === 'on' || args[0] === 'off') {
|
|
@@ -302,7 +302,7 @@ export function parseSceneAdminCommand(command: NativeCommand): ParsedSceneAdmin
|
|
|
302
302
|
command: {
|
|
303
303
|
kind: 'download-media-set',
|
|
304
304
|
sceneKey: args[1] || '',
|
|
305
|
-
enabled: undefined
|
|
305
|
+
enabled: undefined,
|
|
306
306
|
},
|
|
307
307
|
};
|
|
308
308
|
}
|
|
@@ -348,18 +348,3 @@ export function buildPushFailureDebugInfo(args: {
|
|
|
348
348
|
(typeof args.lastError === 'string' && args.lastError) || OUTBOUND_TERMINAL_REASON.PUSH_RETRY,
|
|
349
349
|
};
|
|
350
350
|
}
|
|
351
|
-
|
|
352
|
-
export function buildReplyMediaFallbackDebugInfo(args: {
|
|
353
|
-
sessionKey: string;
|
|
354
|
-
mediaUrl: string;
|
|
355
|
-
replyToId: string;
|
|
356
|
-
fallback: { text: string; reason: string };
|
|
357
|
-
}) {
|
|
358
|
-
return {
|
|
359
|
-
sessionKey: args.sessionKey,
|
|
360
|
-
mediaUrl: args.mediaUrl,
|
|
361
|
-
replyToId: args.replyToId || undefined,
|
|
362
|
-
fallbackText: args.fallback.text,
|
|
363
|
-
reason: args.fallback.reason,
|
|
364
|
-
};
|
|
365
|
-
}
|
|
@@ -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,
|
|
@@ -16,7 +17,6 @@ export {
|
|
|
16
17
|
buildOutboxRouteSelectDebugInfo,
|
|
17
18
|
buildOutboxScheduleDebugInfo,
|
|
18
19
|
buildPushFailureDebugInfo,
|
|
19
|
-
buildReplyMediaFallbackDebugInfo,
|
|
20
20
|
buildRetryRerouteDebugInfo,
|
|
21
21
|
} from './diagnostics-debug-builders.ts';
|
|
22
22
|
|
|
@@ -42,12 +42,6 @@ type OutboxIncidentSummaryInput = {
|
|
|
42
42
|
nowMs?: number;
|
|
43
43
|
};
|
|
44
44
|
|
|
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
45
|
function positiveAgeMs(nowMs: number, at: unknown): number | null {
|
|
52
46
|
const n = finiteNumberOrNull(at);
|
|
53
47
|
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';
|
|
@@ -6,29 +6,35 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Format: [BncrParam:{"key":"val","asVoice":true}]
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
+
* - When path/paths/mediaUrl/mediaUrls are present, probe for reachability.
|
|
16
|
+
* Unreachable single paths keep the marker visible in the output text.
|
|
17
|
+
* Unreachable entries in path arrays are dropped with warnings logged.
|
|
11
18
|
*/
|
|
12
19
|
|
|
13
|
-
|
|
20
|
+
import fs from 'node:fs';
|
|
14
21
|
|
|
15
|
-
|
|
16
|
-
cleanText: string;
|
|
17
|
-
params: Record<string, unknown>;
|
|
18
|
-
} {
|
|
19
|
-
const idx = text.indexOf(PREFIX);
|
|
20
|
-
if (idx === -1) return { cleanText: text, params: {} };
|
|
22
|
+
const PREFIX = '[BncrParam:';
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Locate the closing `]` of a complete marker starting at `prefixIdx`.
|
|
26
|
+
* Returns the index of the closing `]`, or -1 if incomplete.
|
|
27
|
+
*/
|
|
28
|
+
function findMarkerEnd(text: string, prefixIdx: number): number {
|
|
29
|
+
const start = prefixIdx + PREFIX.length;
|
|
30
|
+
if (start >= text.length) return -1;
|
|
24
31
|
|
|
25
32
|
// Scan character-by-character tracking brace/bracket depth so that
|
|
26
|
-
// ] inside JSON values (e.g. CDATA
|
|
33
|
+
// ] inside JSON values (e.g. CDATA / lyric tags) is not mistaken for
|
|
27
34
|
// the closing marker bracket.
|
|
28
35
|
let depth = 0;
|
|
29
36
|
let inString = false;
|
|
30
37
|
let esc = false;
|
|
31
|
-
let end = -1;
|
|
32
38
|
let started = false;
|
|
33
39
|
|
|
34
40
|
for (let i = start; i < text.length; i++) {
|
|
@@ -63,29 +69,62 @@ export function parseBncrMarker(text: string): {
|
|
|
63
69
|
if (depth === 0) {
|
|
64
70
|
// JSON content complete - expect ] to close the marker
|
|
65
71
|
if (i + 1 < text.length && text[i + 1] === ']') {
|
|
66
|
-
|
|
72
|
+
return i + 1;
|
|
67
73
|
}
|
|
68
|
-
|
|
74
|
+
return -1;
|
|
69
75
|
}
|
|
70
76
|
}
|
|
71
77
|
}
|
|
72
78
|
}
|
|
73
79
|
|
|
74
|
-
|
|
80
|
+
return -1;
|
|
81
|
+
}
|
|
75
82
|
|
|
76
|
-
|
|
77
|
-
|
|
83
|
+
export function parseBncrMarker(text: string): {
|
|
84
|
+
cleanText: string;
|
|
85
|
+
params: Record<string, unknown>;
|
|
86
|
+
} {
|
|
87
|
+
const params: Record<string, unknown> = {};
|
|
88
|
+
let result = text;
|
|
89
|
+
let searchFrom = 0;
|
|
78
90
|
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
if (
|
|
82
|
-
|
|
91
|
+
while (true) {
|
|
92
|
+
const idx = result.indexOf(PREFIX, searchFrom);
|
|
93
|
+
if (idx === -1) break;
|
|
94
|
+
|
|
95
|
+
const end = findMarkerEnd(result, idx);
|
|
96
|
+
if (end === -1) {
|
|
97
|
+
// Incomplete marker (missing ]): leave in place,
|
|
98
|
+
// keep looking after this PREFIX so later valid markers are not blocked.
|
|
99
|
+
searchFrom = idx + PREFIX.length;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const rawJson = result.slice(idx + PREFIX.length, end);
|
|
104
|
+
let parsedOk = false;
|
|
105
|
+
try {
|
|
106
|
+
const parsed = JSON.parse(rawJson);
|
|
107
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
108
|
+
Object.assign(params, parsed as Record<string, unknown>);
|
|
109
|
+
parsedOk = true;
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// JSON parse failed — do NOT strip marker so the user can debug it
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (parsedOk) {
|
|
116
|
+
// Strip the complete valid marker
|
|
117
|
+
result = result.slice(0, idx) + result.slice(end + 1);
|
|
118
|
+
searchFrom = idx;
|
|
119
|
+
} else {
|
|
120
|
+
// Leave the invalid marker in place, continue after it
|
|
121
|
+
searchFrom = end + 1;
|
|
83
122
|
}
|
|
84
|
-
} catch {
|
|
85
|
-
// Invalid JSON — remove marker but don't apply params
|
|
86
123
|
}
|
|
87
124
|
|
|
88
|
-
|
|
125
|
+
// Only trim whitespace when markers were actually stripped (result changed).
|
|
126
|
+
// Without markers the original text is returned as-is for maximum fidelity.
|
|
127
|
+
const cleanText = result !== text ? result.replace(/ {2,}/g, ' ').trim() : result;
|
|
89
128
|
return { cleanText, params };
|
|
90
129
|
}
|
|
91
130
|
|
|
@@ -103,6 +142,7 @@ const MEDIA_CONSUMPTION_TYPES = new Set(['file', 'image', 'video', 'audio', 'voi
|
|
|
103
142
|
* based on whether its value is a recognized media type.
|
|
104
143
|
*/
|
|
105
144
|
const CONSUMPTION_FIELDS_NO_TYPE = new Set([
|
|
145
|
+
'ismedia',
|
|
106
146
|
'asVoice',
|
|
107
147
|
'audioAsVoice',
|
|
108
148
|
'kind',
|
|
@@ -110,8 +150,6 @@ const CONSUMPTION_FIELDS_NO_TYPE = new Set([
|
|
|
110
150
|
'downloadMedia',
|
|
111
151
|
]);
|
|
112
152
|
|
|
113
|
-
const CONSUMPTION_FIELDS = new Set([...CONSUMPTION_FIELDS_NO_TYPE]);
|
|
114
|
-
|
|
115
153
|
export function extractConsumptionFields(extra: Record<string, unknown> | undefined): {
|
|
116
154
|
consumed: Partial<{
|
|
117
155
|
asVoice: boolean;
|
|
@@ -120,6 +158,7 @@ export function extractConsumptionFields(extra: Record<string, unknown> | undefi
|
|
|
120
158
|
kind: string;
|
|
121
159
|
replyToId: string;
|
|
122
160
|
downloadMedia: boolean;
|
|
161
|
+
ismedia: boolean;
|
|
123
162
|
}>;
|
|
124
163
|
remaining: Record<string, unknown>;
|
|
125
164
|
} {
|
|
@@ -140,7 +179,7 @@ export function extractConsumptionFields(extra: Record<string, unknown> | undefi
|
|
|
140
179
|
}
|
|
141
180
|
continue;
|
|
142
181
|
}
|
|
143
|
-
if (
|
|
182
|
+
if (CONSUMPTION_FIELDS_NO_TYPE.has(key)) {
|
|
144
183
|
consumed[key] = value;
|
|
145
184
|
} else {
|
|
146
185
|
remaining[key] = value;
|
|
@@ -152,3 +191,213 @@ export function extractConsumptionFields(extra: Record<string, unknown> | undefi
|
|
|
152
191
|
remaining,
|
|
153
192
|
};
|
|
154
193
|
}
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Media path probing
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
const PROBE_TIMEOUT_MS = 5_000;
|
|
200
|
+
const MEDIA_PATH_KEYS = new Set(['path', 'paths', 'mediaUrl', 'mediaUrls']);
|
|
201
|
+
|
|
202
|
+
function hasMediaPathParams(params: Record<string, unknown>): boolean {
|
|
203
|
+
for (const key of MEDIA_PATH_KEYS) {
|
|
204
|
+
if (key in params) return true;
|
|
205
|
+
}
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Check whether a single media path (HTTP URL or local file) is reachable.
|
|
211
|
+
*
|
|
212
|
+
* - HTTP/HTTPS URLs → HEAD request with short timeout (5 s), follows redirects.
|
|
213
|
+
* - Local files → fs.existsSync.
|
|
214
|
+
*
|
|
215
|
+
* Returns `{ ok, reason }` where `reason` describes the failure when `ok` is false.
|
|
216
|
+
*/
|
|
217
|
+
async function probeMediaPath(raw: string): Promise<{
|
|
218
|
+
ok: boolean;
|
|
219
|
+
reason?: string;
|
|
220
|
+
}> {
|
|
221
|
+
const path = String(raw || '').trim();
|
|
222
|
+
if (!path) return { ok: false, reason: 'empty path' };
|
|
223
|
+
|
|
224
|
+
// HTTP(S) URL — HEAD probe
|
|
225
|
+
if (path.startsWith('http://') || path.startsWith('https://')) {
|
|
226
|
+
try {
|
|
227
|
+
const controller = new AbortController();
|
|
228
|
+
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
|
229
|
+
const response = await fetch(path, {
|
|
230
|
+
method: 'HEAD',
|
|
231
|
+
signal: controller.signal,
|
|
232
|
+
redirect: 'follow',
|
|
233
|
+
});
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
if (response.ok || response.status === 206) return { ok: true };
|
|
236
|
+
return { ok: false, reason: `HTTP ${response.status} ${response.statusText}` };
|
|
237
|
+
} catch (err: unknown) {
|
|
238
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
239
|
+
return { ok: false, reason };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Local file
|
|
244
|
+
try {
|
|
245
|
+
if (fs.existsSync(path)) return { ok: true };
|
|
246
|
+
return { ok: false, reason: 'file not found' };
|
|
247
|
+
} catch {
|
|
248
|
+
return { ok: false, reason: 'unreadable path' };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* After JSON marker parsing, probe any media paths found in the params.
|
|
254
|
+
*
|
|
255
|
+
* - Single path (`path` / `mediaUrl`): if unreachable, keeps the marker in text
|
|
256
|
+
* (returns allValid=false, warnings populated).
|
|
257
|
+
* - Path arrays (`paths` / `mediaUrls`): unreachable entries are removed; if at
|
|
258
|
+
* least one entry is valid the marker is still stripped. If ALL entries are
|
|
259
|
+
* invalid the marker is kept.
|
|
260
|
+
*
|
|
261
|
+
* Returns `{ params, warnings, allValid }`:
|
|
262
|
+
* - `params` — (potentially filtered) params with invalid array entries removed.
|
|
263
|
+
* - `warnings` — human-readable warning strings for each unreachable path.
|
|
264
|
+
* - `allValid` — false when a single path or all array entries are unreachable.
|
|
265
|
+
*/
|
|
266
|
+
async function probeAndResolveMarkerPaths(params: Record<string, unknown>): Promise<{
|
|
267
|
+
params: Record<string, unknown>;
|
|
268
|
+
warnings: string[];
|
|
269
|
+
allValid: boolean;
|
|
270
|
+
}> {
|
|
271
|
+
const warnings: string[] = [];
|
|
272
|
+
if (!hasMediaPathParams(params)) return { params, warnings, allValid: true };
|
|
273
|
+
|
|
274
|
+
// Single path / mediaUrl
|
|
275
|
+
const singlePath = String(params.path ?? params.mediaUrl ?? '').trim();
|
|
276
|
+
if (singlePath) {
|
|
277
|
+
const result = await probeMediaPath(singlePath);
|
|
278
|
+
if (!result.ok) {
|
|
279
|
+
warnings.push(`path ${singlePath} unreachable: ${result.reason}`);
|
|
280
|
+
return { params, warnings, allValid: false };
|
|
281
|
+
}
|
|
282
|
+
// Path confirmed reachable — signal downstream that media is validated
|
|
283
|
+
// Only set ismedia:true when user hasn't explicitly opted out.
|
|
284
|
+
if (!('ismedia' in params && params.ismedia === false)) {
|
|
285
|
+
params = { ...params, ismedia: true };
|
|
286
|
+
}
|
|
287
|
+
return { params, warnings, allValid: true };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Path arrays — paths / mediaUrls
|
|
291
|
+
const arraySource = (params.paths ?? params.mediaUrls) as unknown;
|
|
292
|
+
if (Array.isArray(arraySource) && arraySource.length > 0) {
|
|
293
|
+
const entries = arraySource.filter(
|
|
294
|
+
(v): v is string => typeof v === 'string' && v.trim().length > 0,
|
|
295
|
+
);
|
|
296
|
+
if (entries.length === 0) return { params, warnings, allValid: true };
|
|
297
|
+
|
|
298
|
+
const results = await Promise.all(entries.map((p) => probeMediaPath(p)));
|
|
299
|
+
const valid: string[] = [];
|
|
300
|
+
for (let i = 0; i < entries.length; i++) {
|
|
301
|
+
if (results[i].ok) {
|
|
302
|
+
valid.push(entries[i]);
|
|
303
|
+
} else {
|
|
304
|
+
warnings.push(`path ${entries[i]} unreachable: ${results[i].reason}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (valid.length === 0) {
|
|
309
|
+
// All invalid → keep marker
|
|
310
|
+
return { params, warnings, allValid: false };
|
|
311
|
+
}
|
|
312
|
+
// At least one valid → strip marker, replace with filtered entries.
|
|
313
|
+
// Only set ismedia=true when user hasn't explicitly opted out.
|
|
314
|
+
const setMedia = !('ismedia' in params && params.ismedia === false);
|
|
315
|
+
params = { ...params, ...(setMedia ? { ismedia: true } : {}), paths: valid, mediaUrls: valid };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return { params, warnings, allValid: true };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Parse text markers AND probe media paths in one async step.
|
|
323
|
+
*
|
|
324
|
+
/**
|
|
325
|
+
* Unified marker strip decision: the single place that decides whether to strip
|
|
326
|
+
* a [BncrParam:...] marker from the text or leave it in place.
|
|
327
|
+
*
|
|
328
|
+
* - Markers that JSON parse successfully AND whose media paths (if any) are
|
|
329
|
+
* reachable → stripped from text, params extracted and merged.
|
|
330
|
+
* - Markers whose paths are unreachable (single path OR all array entries) →
|
|
331
|
+
* kept in text, NOT stripped, NOT extracted.
|
|
332
|
+
* - Markers without path fields → stripped as normal (existing behavior).
|
|
333
|
+
* - Markers that fail JSON parse → kept in text (existing behavior).
|
|
334
|
+
* - Multiple markers → each evaluated independently, later markers' params
|
|
335
|
+
* overwrite earlier ones when the same key appears.
|
|
336
|
+
*
|
|
337
|
+
* This function IS the single decision point. Downstream consumers of the
|
|
338
|
+
* returned text never re-parse markers — they trust the decision made here.
|
|
339
|
+
*/
|
|
340
|
+
export async function resolveMarkerStripDecision(text: string): Promise<{
|
|
341
|
+
cleanText: string;
|
|
342
|
+
params: Record<string, unknown>;
|
|
343
|
+
warnings: string[];
|
|
344
|
+
}> {
|
|
345
|
+
const params: Record<string, unknown> = {};
|
|
346
|
+
const warnings: string[] = [];
|
|
347
|
+
let result = text;
|
|
348
|
+
let searchFrom = 0;
|
|
349
|
+
const hadMarkers = text.includes(PREFIX);
|
|
350
|
+
|
|
351
|
+
if (!hadMarkers) return { cleanText: text, params, warnings };
|
|
352
|
+
|
|
353
|
+
while (true) {
|
|
354
|
+
const idx = result.indexOf(PREFIX, searchFrom);
|
|
355
|
+
if (idx === -1) break;
|
|
356
|
+
|
|
357
|
+
const end = findMarkerEnd(result, idx);
|
|
358
|
+
if (end === -1) {
|
|
359
|
+
searchFrom = idx + PREFIX.length;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const rawJson = result.slice(idx + PREFIX.length, end);
|
|
364
|
+
let parsed: Record<string, unknown> | null = null;
|
|
365
|
+
try {
|
|
366
|
+
const obj = JSON.parse(rawJson);
|
|
367
|
+
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
368
|
+
parsed = obj as Record<string, unknown>;
|
|
369
|
+
}
|
|
370
|
+
} catch {
|
|
371
|
+
// JSON parse failed → keep marker in text, continue scanning after it
|
|
372
|
+
searchFrom = end + 1;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// JSON parsed but is not a plain object → keep marker in text
|
|
377
|
+
if (!parsed) {
|
|
378
|
+
searchFrom = end + 1;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Marker has path fields → probe before deciding to strip
|
|
383
|
+
if (hasMediaPathParams(parsed)) {
|
|
384
|
+
const probeResult = await probeAndResolveMarkerPaths(parsed);
|
|
385
|
+
if (probeResult.warnings.length > 0) warnings.push(...probeResult.warnings);
|
|
386
|
+
if (!probeResult.allValid) {
|
|
387
|
+
// Path(s) unreachable → keep marker in text, do NOT extract any params
|
|
388
|
+
searchFrom = end + 1;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
// Paths valid → strip, merge params (probeResult.params includes ismedia:true + filtered arrays)
|
|
392
|
+
Object.assign(params, probeResult.params);
|
|
393
|
+
} else {
|
|
394
|
+
// No path fields → strip marker and merge params unconditionally
|
|
395
|
+
Object.assign(params, parsed);
|
|
396
|
+
}
|
|
397
|
+
result = result.slice(0, idx) + result.slice(end + 1);
|
|
398
|
+
searchFrom = idx;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const cleanText = result !== text ? result.replace(/ {2,}/g, ' ').trim() : result;
|
|
402
|
+
return { cleanText, params, warnings };
|
|
403
|
+
}
|
|
@@ -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
|
}
|
|
@@ -19,10 +15,7 @@ export function normalizeMessageText(value: unknown): string {
|
|
|
19
15
|
return asString(value || '').trim();
|
|
20
16
|
}
|
|
21
17
|
|
|
22
|
-
|
|
23
|
-
currentReplyToId: string,
|
|
24
|
-
previousReplyToId: string,
|
|
25
|
-
): boolean {
|
|
18
|
+
function shouldTreatReplyToAsSame(currentReplyToId: string, previousReplyToId: string): boolean {
|
|
26
19
|
if (!currentReplyToId || !previousReplyToId) return true;
|
|
27
20
|
return currentReplyToId === previousReplyToId;
|
|
28
21
|
}
|
|
@@ -1,13 +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
6
|
function isBncrOutboundMessageType(value: string): value is BncrOutboundMessageType {
|
|
12
7
|
return (
|
|
13
8
|
value === 'text' ||
|