@xmoxmo/bncr 0.5.6 → 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.
Files changed (73) hide show
  1. package/dist/index.js +1 -9
  2. package/index.ts +1 -9
  3. package/package.json +1 -1
  4. package/scripts/check-pack.mjs +0 -6
  5. package/scripts/selfcheck.mjs +0 -7
  6. package/src/channel.ts +79 -43
  7. package/src/core/accounts.ts +1 -6
  8. package/src/core/connection-reachability.ts +1 -5
  9. package/src/core/dead-letter-diagnostics.ts +4 -10
  10. package/src/core/outbox-entry-builders.ts +34 -18
  11. package/src/core/outbox-file-transfer-failure.ts +5 -0
  12. package/src/core/outbox-file-transfer-guards.ts +6 -0
  13. package/src/core/outbox-file-transfer-prep.ts +8 -5
  14. package/src/core/outbox-file-transfer-success.ts +5 -0
  15. package/src/core/outbox-queue.ts +1 -7
  16. package/src/core/outbox-summary.ts +15 -27
  17. package/src/core/outbox-text-push-prep.ts +1 -36
  18. package/src/core/permissions.ts +1 -9
  19. package/src/core/persisted-outbox-entry.ts +54 -11
  20. package/src/core/policy.ts +1 -11
  21. package/src/core/register-trace.ts +2 -5
  22. package/src/core/status-meta.ts +1 -9
  23. package/src/core/status.ts +1 -21
  24. package/src/core/targets.ts +52 -70
  25. package/src/core/types.ts +1 -17
  26. package/src/core/value-sanitize.ts +38 -1
  27. package/src/messaging/inbound/commands.ts +2 -24
  28. package/src/messaging/inbound/dispatch-prep.ts +1 -1
  29. package/src/messaging/inbound/gate.ts +2 -31
  30. package/src/messaging/inbound/group-history.ts +0 -75
  31. package/src/messaging/inbound/parse.ts +1 -31
  32. package/src/messaging/inbound/scene-admin.ts +8 -8
  33. package/src/messaging/outbound/diagnostics.ts +1 -6
  34. package/src/messaging/outbound/durable-queue-adapter.ts +17 -11
  35. package/src/messaging/outbound/marker-parser.ts +62 -28
  36. package/src/messaging/outbound/media-dedupe.ts +2 -6
  37. package/src/messaging/outbound/media.ts +1 -6
  38. package/src/messaging/outbound/normalize-outbound-send.ts +327 -0
  39. package/src/messaging/outbound/queue-selectors.ts +1 -5
  40. package/src/messaging/outbound/reply-enqueue.ts +121 -70
  41. package/src/messaging/outbound/session-route.ts +6 -11
  42. package/src/messaging/outbound/target-resolver.ts +1 -6
  43. package/src/openclaw/routing-runtime.ts +0 -11
  44. package/src/openclaw/sdk-helpers.ts +0 -2
  45. package/src/plugin/bridge-file-transfer-push-facade.ts +6 -6
  46. package/src/plugin/bridge-media-facade.ts +0 -1
  47. package/src/plugin/channel-components.ts +0 -13
  48. package/src/plugin/channel-plugin-surface-group.ts +52 -65
  49. package/src/plugin/channel-runtime-builders-delivery.ts +1 -0
  50. package/src/plugin/channel-runtime-types.ts +2 -24
  51. package/src/plugin/channel-send.ts +205 -221
  52. package/src/plugin/channel-utils.ts +8 -29
  53. package/src/plugin/connection-handlers-helpers.ts +0 -15
  54. package/src/plugin/connection-handlers.ts +6 -5
  55. package/src/plugin/file-inbound-abort.ts +5 -0
  56. package/src/plugin/file-inbound-chunk.ts +5 -0
  57. package/src/plugin/file-inbound-complete.ts +5 -0
  58. package/src/plugin/media-dedupe-runtime.ts +1 -6
  59. package/src/plugin/message-send.ts +7 -8
  60. package/src/plugin/outbound.ts +6 -26
  61. package/src/plugin/outbox-drain-loop.ts +2 -2
  62. package/src/plugin/outbox-file-push-flow.ts +7 -7
  63. package/src/plugin/outbox-text-push-flow.ts +1 -1
  64. package/src/plugin/target-runtime.ts +1 -6
  65. package/src/runtime/outbound-ack-timeout.ts +1 -8
  66. package/src/runtime/outbox-transitions.ts +1 -4
  67. package/src/runtime/status-worker.ts +1 -9
  68. package/src/messaging/outbound/build-send-action.ts +0 -130
  69. package/src/messaging/outbound/durable-message-adapter.ts +0 -121
  70. package/src/messaging/outbound/send-params.ts +0 -114
  71. package/src/messaging/outbound/send.ts +0 -127
  72. package/src/openclaw/ingress-runtime.ts +0 -25
  73. package/src/plugin/channel-send-runtime-group.ts +0 -37
package/src/core/types.ts CHANGED
@@ -21,8 +21,6 @@ export type BncrGatewayCapabilityFlags = {
21
21
  inboundOnly: boolean;
22
22
  };
23
23
 
24
- export type FileRecvTransferStatus = 'init' | 'transferring' | 'completed' | 'aborted';
25
-
26
24
  type FileRecvTransferBase = {
27
25
  transferId: string;
28
26
  accountId: string;
@@ -105,21 +103,7 @@ export type OutboxEntry = {
105
103
  accountId: string;
106
104
  sessionKey: string;
107
105
  route: BncrRoute;
108
- payload: Record<string, unknown> & {
109
- _meta?: {
110
- kind?: 'message' | 'file-transfer';
111
- retryCount?: number;
112
- nextAttemptAt?: number;
113
- mediaUrl?: string;
114
- mediaLocalRoots?: string[];
115
- text?: string;
116
- asVoice?: boolean;
117
- audioAsVoice?: boolean;
118
- replyToId?: string;
119
- finalEvent?: string;
120
- [key: string]: unknown;
121
- };
122
- };
106
+ payload: Record<string, unknown>;
123
107
  createdAt: number;
124
108
  retryCount: number;
125
109
  nextAttemptAt: number;
@@ -1,15 +1,39 @@
1
- export function asSanitizedString(value: unknown, fallback = ''): string {
1
+ export function asString(value: unknown, fallback = ''): string {
2
2
  if (typeof value === 'string') return value;
3
3
  if (value == null) return fallback;
4
4
  return String(value);
5
5
  }
6
6
 
7
+ /** Alias: same as asString, named for call-sites that want a sanitization signal. */
8
+ export const asSanitizedString = asString;
9
+
7
10
  export function finiteNumberOr(value: unknown, fallback: number): number {
8
11
  const n = Number(value);
9
12
  return Number.isFinite(n) ? n : fallback;
10
13
  }
11
14
 
15
+ export function now(): number {
16
+ return Date.now();
17
+ }
18
+
19
+ export function asBoolean(value: unknown, fallback = false): boolean {
20
+ if (typeof value === 'boolean') return value;
21
+ if (typeof value === 'number') return value !== 0;
22
+ if (typeof value === 'string') {
23
+ const raw = value.trim().toLowerCase();
24
+ if (!raw) return fallback;
25
+ if (['true', '1', 'yes', 'y', 'on'].includes(raw)) return true;
26
+ if (['false', '0', 'no', 'n', 'off'].includes(raw)) return false;
27
+ }
28
+ return fallback;
29
+ }
30
+
31
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
32
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
33
+ }
34
+
12
35
  export function finiteNumberOrNull(value: unknown): number | null {
36
+ if (value === null || value === undefined) return null;
13
37
  const n = Number(value);
14
38
  return Number.isFinite(n) ? n : null;
15
39
  }
@@ -27,3 +51,16 @@ export function clampFiniteNumber(
27
51
  const finite = finiteNumberOr(value, fallback);
28
52
  return Math.max(min, Math.min(finite, max));
29
53
  }
54
+
55
+ export function asStringArray(v: unknown): string[] {
56
+ if (Array.isArray(v)) {
57
+ return v.map((item) => asString(item).trim()).filter(Boolean);
58
+ }
59
+ if (typeof v === 'string') {
60
+ return v
61
+ .split(',')
62
+ .map((item) => item.trim())
63
+ .filter(Boolean);
64
+ }
65
+ return [];
66
+ }
@@ -7,10 +7,7 @@ import {
7
7
  resolveBncrPinnedMainDmOwnerFromAllowlist,
8
8
  } from '../../openclaw/inbound-session-runtime.ts';
9
9
  import { dispatchOpenClawReplyWithBufferedBlockDispatcher } from '../../openclaw/reply-runtime.ts';
10
- import {
11
- type OpenClawResolvedAgentRoute,
12
- resolveOpenClawAgentRoute,
13
- } from '../../openclaw/routing-runtime.ts';
10
+ import { resolveOpenClawAgentRoute } from '../../openclaw/routing-runtime.ts';
14
11
  import type {
15
12
  BncrEnqueueFromReply,
16
13
  BncrInboundApi,
@@ -18,7 +15,7 @@ import type {
18
15
  BncrInboundLogger,
19
16
  BncrRememberSessionRoute,
20
17
  } from './contracts.ts';
21
- import type { ParsedInbound } from './dispatch-prep.ts';
18
+ import { assertResolvedAgentRoute, type ParsedInbound } from './dispatch-prep.ts';
22
19
  import { buildBncrInboundRecordUpdateLastRoute } from './last-route.ts';
23
20
  import {
24
21
  parseBncrNativeCommand,
@@ -50,25 +47,6 @@ import {
50
47
  } from './session-label.ts';
51
48
  import { createBncrSessionMetaTaskBarrier } from './session-meta-task.ts';
52
49
 
53
- function assertResolvedAgentRoute(resolvedRoute: OpenClawResolvedAgentRoute): {
54
- sessionKey: string;
55
- agentId: string;
56
- mainSessionKey?: string;
57
- } {
58
- const sessionKey =
59
- typeof resolvedRoute.sessionKey === 'string' ? resolvedRoute.sessionKey.trim() : '';
60
- const agentId = typeof resolvedRoute.agentId === 'string' ? resolvedRoute.agentId.trim() : '';
61
- if (!sessionKey) throw new Error('OpenClaw resolveAgentRoute returned empty sessionKey');
62
- if (!agentId) throw new Error('OpenClaw resolveAgentRoute returned empty agentId');
63
- return {
64
- sessionKey,
65
- agentId,
66
- ...(typeof resolvedRoute.mainSessionKey === 'string' && resolvedRoute.mainSessionKey.trim()
67
- ? { mainSessionKey: resolvedRoute.mainSessionKey }
68
- : {}),
69
- };
70
- }
71
-
72
50
  function buildBncrNativeCommandResolvedRoute(args: {
73
51
  api: BncrInboundApi;
74
52
  cfg: BncrInboundConfig;
@@ -95,7 +95,7 @@ const BNCR_GENERIC_MEDIA_TEXTS = new Set([
95
95
  '[文件]',
96
96
  ]);
97
97
 
98
- function assertResolvedAgentRoute(resolvedRoute: OpenClawResolvedAgentRoute): {
98
+ export function assertResolvedAgentRoute(resolvedRoute: OpenClawResolvedAgentRoute): {
99
99
  sessionKey: string;
100
100
  agentId: string;
101
101
  mainSessionKey?: string;
@@ -6,36 +6,7 @@ export type BncrGateResult = { allowed: true } | { allowed: false; reason: strin
6
6
  const REQUIRED_PROTOCOL_VERSION = 'scene-routing-v1';
7
7
  const REQUIRED_CAPABILITY = 'scene-routing-v1';
8
8
 
9
- function asString(v: unknown, fallback = ''): string {
10
- if (typeof v === 'string') return v;
11
- if (v == null) return fallback;
12
- return String(v);
13
- }
14
-
15
- function asBoolean(v: unknown, fallback = false): boolean {
16
- if (typeof v === 'boolean') return v;
17
- if (typeof v === 'number') return v !== 0;
18
- if (typeof v === 'string') {
19
- const raw = v.trim().toLowerCase();
20
- if (!raw) return fallback;
21
- if (['true', '1', 'yes', 'y', 'on'].includes(raw)) return true;
22
- if (['false', '0', 'no', 'n', 'off'].includes(raw)) return false;
23
- }
24
- return fallback;
25
- }
26
-
27
- function asCapabilities(v: unknown): string[] {
28
- if (Array.isArray(v)) {
29
- return v.map((item) => asString(item).trim()).filter(Boolean);
30
- }
31
- if (typeof v === 'string') {
32
- return v
33
- .split(',')
34
- .map((item) => item.trim())
35
- .filter(Boolean);
36
- }
37
- return [];
38
- }
9
+ import { asBoolean, asString, asStringArray } from '../../core/value-sanitize.ts';
39
10
 
40
11
  export async function checkBncrMessageGate(params: {
41
12
  parsed: BncrInboundParamsInput & {
@@ -57,7 +28,7 @@ export async function checkBncrMessageGate(params: {
57
28
  }
58
29
 
59
30
  const protocolVersion = asString(parsed?.protocolVersion || '').trim();
60
- const capabilities = asCapabilities(parsed?.capabilities);
31
+ const capabilities = asStringArray(parsed?.capabilities);
61
32
  if (
62
33
  protocolVersion !== REQUIRED_PROTOCOL_VERSION ||
63
34
  !capabilities.includes(REQUIRED_CAPABILITY)
@@ -1,4 +1,3 @@
1
- import path from 'node:path';
2
1
  import {
3
2
  createChannelHistoryWindow,
4
3
  DEFAULT_GROUP_HISTORY_LIMIT,
@@ -245,80 +244,6 @@ export function collectBncrHistoryMediaFromEntries(args: {
245
244
  }): HistoryMediaEntry[] {
246
245
  return args.entries.flatMap((entry) => (Array.isArray(entry.media) ? entry.media : []));
247
246
  }
248
-
249
- function toBncrPromptMediaPath(value: string | undefined): string | undefined {
250
- const normalized = String(value || '').trim();
251
- if (!normalized) return undefined;
252
- const slashNormalized = normalized.replace(/\\/g, '/');
253
- if (!slashNormalized.includes('/media/inbound/')) return undefined;
254
- const base = path.posix.basename(slashNormalized);
255
- if (!base || base === '.' || base === '..') return undefined;
256
- return `media://inbound/${encodeURIComponent(base)}`;
257
- }
258
-
259
- function buildHistoryWindowMediaSummary(
260
- media: HistoryMediaEntry[] | undefined,
261
- ): string | undefined {
262
- if (!Array.isArray(media) || media.length === 0) return undefined;
263
- const kinds = media
264
- .map((item) =>
265
- String(item?.kind || '')
266
- .trim()
267
- .toLowerCase(),
268
- )
269
- .filter(Boolean);
270
- const uniformKind = kinds[0] && kinds.every((item) => item === kinds[0]) ? kinds[0] : 'document';
271
- const count = media.length;
272
- if (uniformKind === 'image')
273
- return count === 1 ? '<media:image>' : `<media:image> (${count} images)`;
274
- if (uniformKind === 'video')
275
- return count === 1 ? '<media:video>' : `<media:video> (${count} videos)`;
276
- if (uniformKind === 'audio') {
277
- return count === 1 ? '<media:audio>' : `<media:audio> (${count} audio attachments)`;
278
- }
279
- return count === 1 ? '<media:document>' : `<media:document> (${count} attachments)`;
280
- }
281
-
282
- function buildHistoryWindowMedias(media: HistoryMediaEntry[] | undefined) {
283
- if (!Array.isArray(media) || media.length === 0) return [];
284
- return media.flatMap((item) => {
285
- const promptPath = toBncrPromptMediaPath(item?.path);
286
- const payload = {
287
- ...(promptPath ? { path: promptPath } : {}),
288
- ...(item?.contentType ? { contentType: item.contentType } : {}),
289
- ...(item?.kind ? { kind: item.kind } : {}),
290
- ...(item?.messageId ? { messageId: item.messageId } : {}),
291
- };
292
- return Object.keys(payload).length > 0 ? [payload] : [];
293
- });
294
- }
295
-
296
- export function buildBncrHistoryWindowContextFromEntries(args: {
297
- entries: readonly BncrHistoryEntry[];
298
- channelId: string;
299
- }) {
300
- if (args.entries.length === 0) return undefined;
301
- const messages = args.entries.map((entry) => ({
302
- ...(entry.messageId ? { messageId: entry.messageId } : {}),
303
- sender: entry.sender,
304
- senderId: entry.senderId,
305
- ...(typeof entry.timestamp === 'number' ? { timestampMs: entry.timestamp } : {}),
306
- body: entry.body,
307
- mediaSummary: buildHistoryWindowMediaSummary(entry.media),
308
- medias: buildHistoryWindowMedias(entry.media),
309
- }));
310
- return {
311
- label: 'Bncr history window',
312
- source: args.channelId,
313
- type: 'bncr.history_window',
314
- payload: {
315
- relation: 'before_current_message',
316
- order: 'chronological',
317
- messages,
318
- },
319
- };
320
- }
321
-
322
247
  export function clearBncrPendingGroupHistory(args: {
323
248
  historyMap: BncrGroupHistoryMap;
324
249
  parsed: ParsedInbound;
@@ -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 : [];
@@ -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' as any },
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' as any },
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' as any },
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' as any },
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' as any },
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' as any },
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 as any,
305
+ enabled: undefined,
306
306
  },
307
307
  };
308
308
  }
@@ -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 OutboxMeta = NonNullable<OutboxPayload['_meta']>;
70
- type OutboxMessagePayload = { type?: unknown };
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 metaReply = (payload?._meta as OutboxMeta | undefined)?.replyToId;
149
- const replyToId = payload?.replyToId ?? metaReply;
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.message && typeof payload.message === 'object'
163
+ payload?.message && typeof payload.message === 'object'
158
164
  ? (payload.message as OutboxMessagePayload)
159
165
  : null;
160
- if (meta?.kind === 'file-transfer') {
161
- if (meta.asVoice === true || meta.audioAsVoice === true) return 'voice';
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,30 @@
6
6
  *
7
7
  * Format: [BncrParam:{"key":"val","asVoice":true}]
8
8
  *
9
- * Incomplete markers (missing closing ]) are left in text unchanged.
10
- * Invalid JSON within a complete marker - marker is removed but no params.
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).
11
15
  */
12
16
 
13
17
  const PREFIX = '[BncrParam:';
14
18
 
15
- export function parseBncrMarker(text: string): {
16
- cleanText: string;
17
- params: Record<string, unknown>;
18
- } {
19
- const idx = text.indexOf(PREFIX);
20
- if (idx === -1) return { cleanText: text, params: {} };
21
-
22
- const start = idx + PREFIX.length;
23
- if (start >= text.length) return { cleanText: text, params: {} };
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;
24
26
 
25
27
  // Scan character-by-character tracking brace/bracket depth so that
26
- // ] inside JSON values (e.g. CDATA content) is not mistaken for
28
+ // ] inside JSON values (e.g. CDATA / lyric tags) is not mistaken for
27
29
  // the closing marker bracket.
28
30
  let depth = 0;
29
31
  let inString = false;
30
32
  let esc = false;
31
- let end = -1;
32
33
  let started = false;
33
34
 
34
35
  for (let i = start; i < text.length; i++) {
@@ -63,29 +64,62 @@ export function parseBncrMarker(text: string): {
63
64
  if (depth === 0) {
64
65
  // JSON content complete - expect ] to close the marker
65
66
  if (i + 1 < text.length && text[i + 1] === ']') {
66
- end = i + 1;
67
+ return i + 1;
67
68
  }
68
- break;
69
+ return -1;
69
70
  }
70
71
  }
71
72
  }
72
73
  }
73
74
 
74
- if (end === -1) return { cleanText: text, params: {} };
75
+ return -1;
76
+ }
75
77
 
76
- const rawJson = text.slice(start, end);
77
- let params: Record<string, unknown> = {};
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
+ }
78
97
 
79
- try {
80
- const parsed = JSON.parse(rawJson);
81
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
82
- params = parsed as Record<string, unknown>;
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;
83
117
  }
84
- } catch {
85
- // Invalid JSON — remove marker but don't apply params
86
118
  }
87
119
 
88
- const cleanText = (text.slice(0, idx) + text.slice(end + 1)).replace(/ {2,}/g, ' ').trim();
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;
89
123
  return { cleanText, params };
90
124
  }
91
125
 
@@ -103,6 +137,7 @@ const MEDIA_CONSUMPTION_TYPES = new Set(['file', 'image', 'video', 'audio', 'voi
103
137
  * based on whether its value is a recognized media type.
104
138
  */
105
139
  const CONSUMPTION_FIELDS_NO_TYPE = new Set([
140
+ 'ismedia',
106
141
  'asVoice',
107
142
  'audioAsVoice',
108
143
  'kind',
@@ -110,8 +145,6 @@ const CONSUMPTION_FIELDS_NO_TYPE = new Set([
110
145
  'downloadMedia',
111
146
  ]);
112
147
 
113
- const CONSUMPTION_FIELDS = new Set([...CONSUMPTION_FIELDS_NO_TYPE]);
114
-
115
148
  export function extractConsumptionFields(extra: Record<string, unknown> | undefined): {
116
149
  consumed: Partial<{
117
150
  asVoice: boolean;
@@ -120,6 +153,7 @@ export function extractConsumptionFields(extra: Record<string, unknown> | undefi
120
153
  kind: string;
121
154
  replyToId: string;
122
155
  downloadMedia: boolean;
156
+ ismedia: boolean;
123
157
  }>;
124
158
  remaining: Record<string, unknown>;
125
159
  } {
@@ -140,7 +174,7 @@ export function extractConsumptionFields(extra: Record<string, unknown> | undefi
140
174
  }
141
175
  continue;
142
176
  }
143
- if (CONSUMPTION_FIELDS.has(key)) {
177
+ if (CONSUMPTION_FIELDS_NO_TYPE.has(key)) {
144
178
  consumed[key] = value;
145
179
  } else {
146
180
  remaining[key] = value;
@@ -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,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' ||