@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.
Files changed (78) 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 +114 -37
  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 +38 -15
  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/native-command.ts +7 -3
  32. package/src/messaging/inbound/parse.ts +1 -31
  33. package/src/messaging/inbound/scene-admin.ts +181 -5
  34. package/src/messaging/outbound/diagnostics.ts +1 -6
  35. package/src/messaging/outbound/durable-queue-adapter.ts +17 -11
  36. package/src/messaging/outbound/marker-parser.ts +188 -0
  37. package/src/messaging/outbound/media-dedupe.ts +2 -6
  38. package/src/messaging/outbound/media.ts +6 -20
  39. package/src/messaging/outbound/normalize-outbound-send.ts +327 -0
  40. package/src/messaging/outbound/queue-selectors.ts +1 -5
  41. package/src/messaging/outbound/reply-enqueue-media.ts +4 -0
  42. package/src/messaging/outbound/reply-enqueue.ts +130 -57
  43. package/src/messaging/outbound/session-route.ts +6 -11
  44. package/src/messaging/outbound/target-resolver.ts +1 -6
  45. package/src/openclaw/routing-runtime.ts +0 -11
  46. package/src/openclaw/sdk-helpers.ts +0 -2
  47. package/src/plugin/bridge-file-transfer-push-facade.ts +8 -6
  48. package/src/plugin/bridge-media-facade.ts +1 -1
  49. package/src/plugin/channel-components.ts +0 -13
  50. package/src/plugin/channel-plugin-surface-group.ts +57 -40
  51. package/src/plugin/channel-runtime-builders-delivery.ts +3 -0
  52. package/src/plugin/channel-runtime-types.ts +10 -24
  53. package/src/plugin/channel-send.ts +241 -154
  54. package/src/plugin/channel-utils.ts +8 -29
  55. package/src/plugin/connection-handlers-helpers.ts +0 -15
  56. package/src/plugin/connection-handlers.ts +6 -5
  57. package/src/plugin/file-inbound-abort.ts +5 -0
  58. package/src/plugin/file-inbound-chunk.ts +5 -0
  59. package/src/plugin/file-inbound-complete.ts +5 -0
  60. package/src/plugin/file-transfer-orchestrator.ts +105 -3
  61. package/src/plugin/file-transfer-setup.ts +1 -1
  62. package/src/plugin/media-dedupe-runtime.ts +1 -6
  63. package/src/plugin/media-orchestrators-runtime-group.ts +2 -0
  64. package/src/plugin/message-send.ts +7 -8
  65. package/src/plugin/outbound.ts +6 -22
  66. package/src/plugin/outbox-drain-loop.ts +2 -2
  67. package/src/plugin/outbox-file-push-flow.ts +7 -7
  68. package/src/plugin/outbox-text-push-flow.ts +1 -1
  69. package/src/plugin/target-runtime.ts +1 -6
  70. package/src/runtime/outbound-ack-timeout.ts +1 -8
  71. package/src/runtime/outbox-transitions.ts +1 -4
  72. package/src/runtime/status-worker.ts +1 -9
  73. package/src/messaging/outbound/build-send-action.ts +0 -130
  74. package/src/messaging/outbound/durable-message-adapter.ts +0 -121
  75. package/src/messaging/outbound/send-params.ts +0 -81
  76. package/src/messaging/outbound/send.ts +0 -121
  77. package/src/openclaw/ingress-runtime.ts +0 -25
  78. package/src/plugin/channel-send-runtime-group.ts +0 -37
@@ -1,6 +1,7 @@
1
1
  import { buildFileTransferInitPayload } from '../core/file-transfer-payloads.ts';
2
2
  import type { BncrRoute, FileSendTransferState } from '../core/types.ts';
3
3
  import { asSanitizedString, clampFiniteNumber } from '../core/value-sanitize.ts';
4
+ import { resolveOutboundFileName } from './channel-utils.ts';
4
5
  import type { createBncrFileAckRuntime } from './file-ack-runtime.ts';
5
6
  import {
6
7
  abortChunkTransfer,
@@ -36,13 +37,13 @@ function getCompletedTransferPath(state?: FileSendTransferState): string | null
36
37
  function buildBase64TransferResult(prepared: {
37
38
  mimeType?: string;
38
39
  fileName?: string;
39
- mediaBase64?: string;
40
+ base64?: string;
40
41
  }) {
41
42
  return {
42
43
  mode: 'base64' as const,
43
44
  mimeType: prepared.mimeType,
44
45
  fileName: prepared.fileName,
45
- mediaBase64: prepared.mediaBase64,
46
+ base64: prepared.base64,
46
47
  };
47
48
  }
48
49
 
@@ -190,8 +191,96 @@ export function createBncrFileTransferOrchestrator(runtime: BncrFileTransferOrch
190
191
 
191
192
  return buildChunkTransferResult({ mimeType, fileName, path: done.path });
192
193
  };
194
+ const HTTP_URL_RE = /^https?:\/\//i;
195
+
196
+ /** Try HEAD for Content-Type; fall back to Range+sniff for magic bytes when unclear. */
197
+ async function resolveRemoteMediaType(url: string): Promise<{ mimeType?: string }> {
198
+ // 1) HEAD -> Content-Type
199
+ let headerType = '';
200
+ try {
201
+ const head = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(10_000) });
202
+ if (head.ok) {
203
+ const raw = (head.headers.get('content-type') || '').trim().toLowerCase();
204
+ const parsed = raw.split(';')[0]?.trim() || '';
205
+ // Accept explicit types; reject octet-stream and generic application/*
206
+ if (parsed && parsed !== 'application/octet-stream') {
207
+ headerType = parsed;
208
+ }
209
+ }
210
+ } catch {
211
+ /* HEAD failed, fall through to Range sniff */
212
+ }
213
+
214
+ if (headerType) return { mimeType: headerType };
215
+
216
+ // 2) Range GET -> first 512 bytes -> magic byte sniff
217
+ try {
218
+ const range = await fetch(url, {
219
+ method: 'GET',
220
+ headers: { Range: 'bytes=0-511' },
221
+ signal: AbortSignal.timeout(10_000),
222
+ });
223
+ // Only accept 206 Partial Content; 200 (no Range support) or other statuses -> fall through
224
+ if (range.status !== 206) return {};
225
+ const buf = Buffer.from(await range.arrayBuffer());
226
+ if (buf.length < 4) return {};
227
+
228
+ const sniffed = sniffMimeFromMagic(buf);
229
+ if (!sniffed) return {};
230
+ return { mimeType: sniffed };
231
+ } catch {
232
+ return {};
233
+ }
234
+ }
235
+
236
+ /** Minimal magic-byte MIME sniffer for the first ~512 bytes. */
237
+ function sniffMimeFromMagic(buf: Buffer): string | undefined {
238
+ const eq = (off: number, ...bytes: number[]) =>
239
+ bytes.every((b, idx) => off + idx < buf.length && buf[off + idx] === b);
240
+
241
+ // JPEG: FF D8 FF
242
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff)
243
+ return 'image/jpeg';
244
+ // PNG: 89 50 4E 47
245
+ if (eq(0, 0x89, 0x50, 0x4e, 0x47)) return 'image/png';
246
+ // GIF: 47 49 46 38
247
+ if (eq(0, 0x47, 0x49, 0x46, 0x38)) return 'image/gif';
248
+ // WEBP: RIFF .... WEBP
249
+ if (eq(0, 0x52, 0x49, 0x46, 0x46) && buf.length >= 12 && eq(8, 0x57, 0x45, 0x42, 0x50))
250
+ return 'image/webp';
251
+ // BMP: 42 4D
252
+ if (eq(0, 0x42, 0x4d)) return 'image/bmp';
253
+ // MP4 / MOV / M4A: ftyp box
254
+ if (eq(4, 0x66, 0x74, 0x79, 0x70) && buf.length >= 12) {
255
+ const brand = String.fromCharCode(buf[8], buf[9], buf[10], buf[11]);
256
+ if (brand === 'M4A ' || brand === 'mp42') return 'audio/mp4';
257
+ return 'video/mp4';
258
+ }
259
+ // MP3: ID3 tag
260
+ if (eq(0, 0x49, 0x44, 0x33)) return 'audio/mpeg';
261
+ // MPEG audio sync word
262
+ if (buf[0] === 0xff && (buf[1] & 0xe0) === 0xe0) return 'audio/mpeg';
263
+ // OGG: 4F 67 67 53
264
+ if (eq(0, 0x4f, 0x67, 0x67, 0x53)) return 'audio/ogg';
265
+ // WAV: RIFF .... WAVE
266
+ if (eq(0, 0x52, 0x49, 0x46, 0x46) && buf.length >= 12 && eq(8, 0x57, 0x41, 0x56, 0x45))
267
+ return 'audio/wav';
268
+ // FLAC: 66 4C 61 43
269
+ if (eq(0, 0x66, 0x4c, 0x61, 0x43)) return 'audio/flac';
270
+ // PDF: 25 50 44 46
271
+ if (eq(0, 0x25, 0x50, 0x44, 0x46)) return 'application/pdf';
272
+ // WebM/Matroska: 1A 45 DF A3
273
+ if (eq(0, 0x1a, 0x45, 0xdf, 0xa3)) {
274
+ for (let i = 0; i < Math.min(buf.length, 200); i++) {
275
+ if (buf[i] === 0xae) return 'video/webm';
276
+ }
277
+ return 'audio/webm';
278
+ }
279
+ return undefined;
280
+ }
193
281
 
194
282
  const transferMediaToBncrClient = async (params: {
283
+ downloadMedia?: boolean;
195
284
  accountId: string;
196
285
  sessionKey: string;
197
286
  route: BncrRoute;
@@ -201,9 +290,22 @@ export function createBncrFileTransferOrchestrator(runtime: BncrFileTransferOrch
201
290
  mode: 'base64' | 'chunk';
202
291
  mimeType?: string;
203
292
  fileName?: string;
204
- mediaBase64?: string;
293
+ base64?: string;
205
294
  path?: string;
206
295
  }> => {
296
+ // HTTP URL: skip download/chunk, pass URL through to client
297
+ if (HTTP_URL_RE.test(params.mediaUrl) && !params.downloadMedia) {
298
+ const { mimeType } = await resolveRemoteMediaType(params.mediaUrl);
299
+ const fileName = resolveOutboundFileName({
300
+ mediaUrl: params.mediaUrl,
301
+ mimeType,
302
+ });
303
+ return {
304
+ mode: 'base64' as const,
305
+ mimeType,
306
+ fileName,
307
+ };
308
+ }
207
309
  const prepared = await runtime.prepareOutboundTransfer({
208
310
  accountId: params.accountId,
209
311
  sessionKey: params.sessionKey,
@@ -116,7 +116,7 @@ export function createBncrFileTransferSetup(runtime: BncrFileTransferSetupRuntim
116
116
  mode: 'base64' as const,
117
117
  mimeType,
118
118
  fileName,
119
- mediaBase64: loaded.buffer.toString('base64'),
119
+ base64: loaded.buffer.toString('base64'),
120
120
  };
121
121
  }
122
122
 
@@ -1,3 +1,4 @@
1
+ import { asString } from '../core/value-sanitize.ts';
1
2
  import {
2
3
  buildMediaTextFallback,
3
4
  type MediaDedupeCacheEntry,
@@ -5,12 +6,6 @@ import {
5
6
  normalizeReplyToId,
6
7
  } from '../messaging/outbound/media-dedupe.ts';
7
8
 
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
9
  export function createBncrMediaDedupeRuntime(runtime: {
15
10
  now: () => number;
16
11
  recentMediaDedupeBySession: Map<string, Map<string, MediaDedupeCacheEntry>>;
@@ -53,6 +53,7 @@ function buildReplyMediaEntryHelpers(runtime: {
53
53
  kind?: 'tool' | 'block' | 'final';
54
54
  replyToId?: string;
55
55
  replyTargetPolicy?: OutboundReplyTargetPolicy;
56
+ downloadMedia?: boolean;
56
57
  }) => OutboxEntry;
57
58
  rememberRecentMediaSend: (args: {
58
59
  sessionKey: string;
@@ -208,6 +209,7 @@ export function createBncrMediaOrchestratorsRuntimeGroup(runtime: {
208
209
  kind?: 'tool' | 'block' | 'final';
209
210
  replyToId?: string;
210
211
  replyTargetPolicy?: OutboundReplyTargetPolicy;
212
+ downloadMedia?: boolean;
211
213
  }) => OutboxEntry;
212
214
  rememberRecentMediaSend: (args: {
213
215
  sessionKey: string;
@@ -1,23 +1,22 @@
1
1
  import type { ChannelMessageSendResult } from 'openclaw/plugin-sdk/channel-message';
2
-
3
- type MessageSendContext = Record<string, unknown>;
2
+ import type { BncrChannelSendContext } from './channel-runtime-types.ts';
4
3
 
5
4
  export type BncrMessageSendBridge = {
6
5
  channelMessageSendText: (
7
- ctx: MessageSendContext,
6
+ ctx: BncrChannelSendContext,
8
7
  ) => ChannelMessageSendResult | Promise<ChannelMessageSendResult>;
9
8
  channelMessageSendMedia: (
10
- ctx: MessageSendContext,
9
+ ctx: BncrChannelSendContext,
11
10
  ) => ChannelMessageSendResult | Promise<ChannelMessageSendResult>;
12
11
  channelMessageSendPayload: (
13
- ctx: MessageSendContext,
12
+ ctx: BncrChannelSendContext,
14
13
  ) => ChannelMessageSendResult | Promise<ChannelMessageSendResult>;
15
14
  };
16
15
 
17
16
  export function createBncrMessageSend(getBridge: () => BncrMessageSendBridge) {
18
17
  return {
19
- text: async (ctx: MessageSendContext) => getBridge().channelMessageSendText(ctx),
20
- media: async (ctx: MessageSendContext) => getBridge().channelMessageSendMedia(ctx),
21
- payload: async (ctx: MessageSendContext) => getBridge().channelMessageSendPayload(ctx),
18
+ text: async (ctx: BncrChannelSendContext) => getBridge().channelMessageSendText(ctx),
19
+ media: async (ctx: BncrChannelSendContext) => getBridge().channelMessageSendMedia(ctx),
20
+ payload: async (ctx: BncrChannelSendContext) => getBridge().channelMessageSendPayload(ctx),
22
21
  };
23
22
  }
@@ -1,31 +1,15 @@
1
1
  import { normalizeAccountId } from '../core/accounts.ts';
2
+ import { asString } from '../core/value-sanitize.ts';
2
3
  import {
3
4
  deleteBncrMessageAction,
4
5
  editBncrMessageAction,
5
6
  reactBncrMessageAction,
6
7
  sendBncrReplyAction,
7
8
  } from '../messaging/outbound/actions.ts';
8
-
9
- function asString(v: unknown, fallback = ''): string {
10
- return typeof v === 'string' ? v : v == null ? fallback : String(v);
11
- }
9
+ import type { BncrChannelSendContext } from './channel-runtime-types.ts';
12
10
 
13
11
  type BncrOutboundDeliveryResult = { channel: string; messageId: string; chatId: string };
14
12
 
15
- type BncrOutboundSendContext = {
16
- accountId?: string | null;
17
- to?: string;
18
- text?: string;
19
- mediaUrl?: string;
20
- type?: string;
21
- kind?: string;
22
- replyToId?: string | null;
23
- replyToMessageId?: string | null;
24
- asVoice?: boolean;
25
- audioAsVoice?: boolean;
26
- mediaLocalRoots?: readonly string[];
27
- };
28
-
29
13
  type BncrOutboundReplyActionContext = {
30
14
  accountId?: string | null;
31
15
  to?: string;
@@ -50,18 +34,18 @@ type BncrOutboundEditActionContext = BncrOutboundTargetActionContext & {
50
34
 
51
35
  export type BncrOutboundBridge = {
52
36
  channelSendText: (
53
- ctx: BncrOutboundSendContext,
37
+ ctx: BncrChannelSendContext,
54
38
  ) => BncrOutboundDeliveryResult | Promise<BncrOutboundDeliveryResult>;
55
39
  channelSendMedia: (
56
- ctx: BncrOutboundSendContext,
40
+ ctx: BncrChannelSendContext,
57
41
  ) => BncrOutboundDeliveryResult | Promise<BncrOutboundDeliveryResult>;
58
42
  };
59
43
 
60
44
  export function createBncrOutboundRuntime(getBridge: () => BncrOutboundBridge) {
61
45
  return {
62
46
  deliveryMode: 'gateway' as const,
63
- sendText: async (ctx: BncrOutboundSendContext) => getBridge().channelSendText(ctx),
64
- sendMedia: async (ctx: BncrOutboundSendContext) => getBridge().channelSendMedia(ctx),
47
+ sendText: async (ctx: BncrChannelSendContext) => getBridge().channelSendText(ctx),
48
+ sendMedia: async (ctx: BncrChannelSendContext) => getBridge().channelSendMedia(ctx),
65
49
  replyAction: async (ctx: BncrOutboundReplyActionContext) =>
66
50
  sendBncrReplyAction({
67
51
  accountId: normalizeAccountId(ctx?.accountId),
@@ -295,8 +295,8 @@ export function createBncrOutboxDrainLoop(
295
295
  try {
296
296
  pushed = await runtime.tryPushEntry(entry);
297
297
  } catch (error) {
298
- const meta = runtime.isPlainObject(entry.payload?._meta) ? entry.payload._meta : null;
299
- if (meta?.kind === 'file-transfer') {
298
+ const msg = runtime.isPlainObject(entry.payload?.message) ? entry.payload.message : null;
299
+ if (msg?.transferMode === 'media') {
300
300
  runtime.handleFileTransferPushFailure({ entry, error });
301
301
  } else {
302
302
  runtime.handleTextPushFailure({ entry, error });
@@ -1,11 +1,11 @@
1
1
  import { resolveFileTransferGuard } from '../core/outbox-file-transfer-guards.ts';
2
- import { prepareFileTransferRouteSelection } from '../core/outbox-file-transfer-prep.ts';
2
+ import { prepareRouteSelection } from '../core/outbox-file-transfer-prep.ts';
3
3
  import type { BncrConnection, OutboxEntry } from '../core/types.ts';
4
4
  import { selectOutboxFileTransferRouteCandidates } from '../messaging/outbound/queue-selectors.ts';
5
5
 
6
6
  export async function runBncrFileTransferOutboxPush(args: {
7
7
  entry: OutboxEntry;
8
- meta: Record<string, unknown>;
8
+ msg: Record<string, unknown>;
9
9
  gatewayContext: unknown;
10
10
  owner: BncrConnection | null;
11
11
  resolvePushConnIds: (accountId: string) => Set<string>;
@@ -18,7 +18,7 @@ export async function runBncrFileTransferOutboxPush(args: {
18
18
  }) => void;
19
19
  pushFileTransferSuccessPath: (args: {
20
20
  entry: OutboxEntry;
21
- meta: Record<string, unknown>;
21
+ msg: Record<string, unknown>;
22
22
  owner: BncrConnection | null;
23
23
  connIds: Iterable<string>;
24
24
  recentInboundReachable: boolean;
@@ -27,21 +27,21 @@ export async function runBncrFileTransferOutboxPush(args: {
27
27
  }) => Promise<void>;
28
28
  handleFileTransferPushFailure: (args: { entry: OutboxEntry; error: unknown }) => void;
29
29
  }) {
30
- const selection = prepareFileTransferRouteSelection({
30
+ const selection = prepareRouteSelection({
31
31
  entry: args.entry,
32
32
  owner: args.owner,
33
33
  resolvePushConnIds: args.resolvePushConnIds,
34
34
  resolveRecentInboundConnIds: args.resolveRecentInboundConnIds,
35
35
  hasRecentInboundReachability: args.hasRecentInboundReachability,
36
36
  isRevalidatedAttemptedConn: args.isRevalidatedAttemptedConn,
37
- selectOutboxFileTransferRouteCandidates,
37
+ selectRouteCandidates: selectOutboxFileTransferRouteCandidates,
38
38
  });
39
39
  const guard = resolveFileTransferGuard({
40
40
  gatewayContext: args.gatewayContext,
41
41
  entry: args.entry,
42
42
  owner: args.owner,
43
43
  routeSelection: selection,
44
- mediaUrl: String(args.meta.mediaUrl || '').trim(),
44
+ mediaUrl: String(args.msg.mediaUrl || '').trim(),
45
45
  });
46
46
  if (!guard.ok) {
47
47
  args.handleFileTransferPushGuardFailure({
@@ -54,7 +54,7 @@ export async function runBncrFileTransferOutboxPush(args: {
54
54
  try {
55
55
  await args.pushFileTransferSuccessPath({
56
56
  entry: args.entry,
57
- meta: args.meta,
57
+ msg: args.msg,
58
58
  owner: args.owner,
59
59
  connIds: guard.connIds,
60
60
  recentInboundReachable: guard.recentInboundReachable,
@@ -43,7 +43,7 @@ export async function runBncrTextOutboxPush(args: {
43
43
  resolveRecentInboundConnIds: args.resolveRecentInboundConnIds,
44
44
  hasRecentInboundReachability: args.hasRecentInboundReachability,
45
45
  isRevalidatedAttemptedConn: args.isRevalidatedAttemptedConn,
46
- selectOutboxRouteCandidates,
46
+ selectRouteCandidates: selectOutboxRouteCandidates,
47
47
  });
48
48
  const guard = resolveTextPushGuard({
49
49
  gatewayContext: args.gatewayContext,
@@ -6,15 +6,10 @@ import {
6
6
  routeKey,
7
7
  } from '../core/targets.ts';
8
8
  import type { BncrRoute } from '../core/types.ts';
9
+ import { asString } from '../core/value-sanitize.ts';
9
10
  import { getOpenClawRuntimeConfigOrDefault } from '../openclaw/config-runtime.ts';
10
11
  import type { BncrChannelConfigRoot } from './channel-runtime-types.ts';
11
12
 
12
- function asString(v: unknown, fallback = ''): string {
13
- if (typeof v === 'string') return v;
14
- if (v == null) return fallback;
15
- return String(v);
16
- }
17
-
18
13
  export function createBncrTargetRuntime(runtime: {
19
14
  api: Parameters<typeof getOpenClawRuntimeConfigOrDefault>[0];
20
15
  channelId: string;
@@ -1,4 +1,5 @@
1
1
  import type { BncrAckObservability, BncrAckStrategy } from '../core/types.ts';
2
+ import { asString, finiteNumberOr } from '../core/value-sanitize.ts';
2
3
 
3
4
  type ComputeBncrRecommendedAckTimeoutArgs = {
4
5
  lateAckOkCount: number;
@@ -76,14 +77,6 @@ export function resolveBncrRuntimeAckTimeoutDecision(args: ComputeBncrRecommende
76
77
  return { timeoutMs, reason };
77
78
  }
78
79
 
79
- function finiteNumberOr(value: unknown, fallback: number) {
80
- return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
81
- }
82
-
83
- function asString(value: unknown, fallback = '') {
84
- return typeof value === 'string' ? value : fallback;
85
- }
86
-
87
80
  export function buildBncrRuntimeAckStrategy(args: {
88
81
  ackObservability: BncrAckObservability;
89
82
  defaultAckTimeoutMs: number;
@@ -1,4 +1,5 @@
1
1
  import type { OutboxEntry } from '../core/types.ts';
2
+ import { finiteNumberOr } from '../core/value-sanitize.ts';
2
3
  import type {
3
4
  PushFailureDecision,
4
5
  RetryRerouteDecision,
@@ -83,10 +84,6 @@ export function buildBncrOutboxPushSuccessEntryPatch(args: {
83
84
  };
84
85
  }
85
86
 
86
- function finiteNumberOr(value: unknown, fallback: number): number {
87
- return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
88
- }
89
-
90
87
  export function buildBncrAckOkTelemetryPatch(args: {
91
88
  entry: OutboxEntry;
92
89
  ackAt: number;
@@ -1,5 +1,6 @@
1
1
  import { normalizeAccountId } from '../core/accounts.ts';
2
2
  import type { BncrDiagnosticsSummary } from '../core/types.ts';
3
+ import { finiteNumberOr, nonNegativeFiniteNumberOr } from '../core/value-sanitize.ts';
3
4
 
4
5
  type StatusRuntimeMeta = {
5
6
  pending?: number;
@@ -60,15 +61,6 @@ export function createHealthStatusLogState(): HealthStatusLogState {
60
61
  return { emittedSig: null, pendingSig: null, pendingSince: 0 };
61
62
  }
62
63
 
63
- function finiteNumberOr(value: unknown, fallback: number): number {
64
- const n = Number(value);
65
- return Number.isFinite(n) ? n : fallback;
66
- }
67
-
68
- function nonNegativeFiniteNumberOr(value: unknown, fallback: number): number {
69
- return Math.max(0, finiteNumberOr(value, fallback));
70
- }
71
-
72
64
  export function updateHealthStatusLogState(args: {
73
65
  state: HealthStatusLogState;
74
66
  sig: string;
@@ -1,130 +0,0 @@
1
- import { randomUUID } from 'node:crypto';
2
-
3
- type MinimalBncrSendInput = {
4
- channel?: string;
5
- action?: string;
6
- idempotencyKey?: string;
7
- accountId?: string;
8
- to?: string;
9
- message?: string;
10
- caption?: string;
11
- path?: string;
12
- media?: string;
13
- filePath?: string;
14
- mediaUrl?: string;
15
- mediaUrls?: string[];
16
- asVoice?: boolean;
17
- audioAsVoice?: boolean;
18
- params?: Record<string, unknown>;
19
- };
20
-
21
- type BuiltBncrMessageAction = {
22
- channel: string;
23
- action: string;
24
- idempotencyKey: string;
25
- accountId?: string;
26
- params: Record<string, unknown>;
27
- };
28
-
29
- function asString(v: unknown, fallback = ''): string {
30
- if (typeof v === 'string') return v;
31
- if (v == null) return fallback;
32
- return String(v);
33
- }
34
-
35
- function isPlainObject(value: unknown): value is Record<string, unknown> {
36
- return typeof value === 'object' && value !== null && !Array.isArray(value);
37
- }
38
-
39
- function pickFirstString(...values: unknown[]): string | undefined {
40
- for (const value of values) {
41
- if (typeof value !== 'string') continue;
42
- if (value.length === 0) return '';
43
- return value;
44
- }
45
- return undefined;
46
- }
47
-
48
- function pickFirstBoolean(...values: unknown[]): boolean | undefined {
49
- for (const value of values) {
50
- if (typeof value === 'boolean') return value;
51
- }
52
- return undefined;
53
- }
54
-
55
- export function buildBncrMessageAction(input: MinimalBncrSendInput): BuiltBncrMessageAction {
56
- const paramsObj = isPlainObject(input.params) ? input.params : {};
57
-
58
- const channel = asString(input.channel || 'bncr').trim() || 'bncr';
59
- const action = asString(input.action || 'send').trim() || 'send';
60
- const idempotencyKey = asString(input.idempotencyKey || '').trim() || `bncr-${randomUUID()}`;
61
- const accountId =
62
- asString(pickFirstString(paramsObj.accountId, input.accountId) || '').trim() || undefined;
63
-
64
- const to = asString(pickFirstString(paramsObj.to, input.to) || '').trim();
65
- if (!to) throw new Error('bncr send requires to');
66
-
67
- const mediaPath = pickFirstString(
68
- paramsObj.media,
69
- paramsObj.path,
70
- paramsObj.filePath,
71
- paramsObj.mediaUrl,
72
- input.media,
73
- input.path,
74
- input.filePath,
75
- input.mediaUrl,
76
- );
77
- const rawMediaUrls = Array.isArray(paramsObj.mediaUrls)
78
- ? paramsObj.mediaUrls
79
- : Array.isArray(input.mediaUrls)
80
- ? input.mediaUrls
81
- : undefined;
82
- const mediaUrls = rawMediaUrls?.map((value) => asString(value || '').trim()).filter(Boolean);
83
-
84
- const message = pickFirstString(paramsObj.message, input.message) ?? '';
85
- const explicitCaption = pickFirstString(paramsObj.caption, input.caption) ?? '';
86
- const asVoice = pickFirstBoolean(paramsObj.asVoice, input.asVoice);
87
- const audioAsVoice = pickFirstBoolean(paramsObj.audioAsVoice, input.audioAsVoice);
88
-
89
- if ((asVoice === true || audioAsVoice === true) && !mediaPath && !mediaUrls?.length) {
90
- throw new Error('bncr voice send requires media path');
91
- }
92
-
93
- const normalizedParams: Record<string, unknown> = {
94
- ...paramsObj,
95
- to,
96
- };
97
-
98
- if (mediaPath) {
99
- normalizedParams.path = mediaPath;
100
- const finalCaption = explicitCaption || message;
101
- if (finalCaption) normalizedParams.caption = finalCaption;
102
- delete normalizedParams.message;
103
- if (mediaUrls?.length) normalizedParams.mediaUrls = mediaUrls;
104
- } else {
105
- if (mediaUrls?.length) {
106
- normalizedParams.mediaUrls = mediaUrls;
107
- const finalCaption = explicitCaption || message;
108
- if (finalCaption) normalizedParams.caption = finalCaption;
109
- delete normalizedParams.message;
110
- delete normalizedParams.path;
111
- } else {
112
- const finalMessage = message || explicitCaption;
113
- if (!finalMessage.trim()) throw new Error('bncr send requires message or media');
114
- normalizedParams.message = finalMessage;
115
- delete normalizedParams.caption;
116
- }
117
- }
118
-
119
- if (asVoice === true) normalizedParams.asVoice = true;
120
- if (audioAsVoice === true) normalizedParams.audioAsVoice = true;
121
- if (accountId) normalizedParams.accountId = accountId;
122
-
123
- return {
124
- channel,
125
- action,
126
- idempotencyKey,
127
- ...(accountId ? { accountId } : {}),
128
- params: normalizedParams,
129
- };
130
- }