@xmoxmo/bncr 0.5.4 → 0.5.6

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.
@@ -3,6 +3,7 @@ import { normalizeAccountId } from '../core/accounts.ts';
3
3
  import { buildBncrDebugJsonMessage } from '../core/logging.ts';
4
4
  import type { BncrRoute, OutboxEntry } from '../core/types.ts';
5
5
  import { buildBncrDurableQueuedResult } from '../messaging/outbound/durable-queue-adapter.ts';
6
+ import { extractConsumptionFields, parseBncrMarker } from '../messaging/outbound/marker-parser.ts';
6
7
  import type { ReplyPayloadInput } from '../messaging/outbound/reply-enqueue.ts';
7
8
  import { sendBncrMedia, sendBncrText } from '../messaging/outbound/send.ts';
8
9
  import type { BncrChannelSendContext, BncrVerifiedTarget } from './channel-runtime-types.ts';
@@ -28,6 +29,47 @@ export type BncrChannelSendRuntime = {
28
29
  listOutboxEntries: () => OutboxEntry[];
29
30
  };
30
31
 
32
+ /**
33
+ * Merge all extra sources (ctx.extra, marker params, host-level fields)
34
+ * into a single extra bag. Strips consumption fields and returns them
35
+ * as independent params, remaining extra passes through the pipeline.
36
+ */
37
+ function resolveUnifiedOutboundExtra(ctx: BncrChannelSendContext): {
38
+ cleanText: string;
39
+ extra: Record<string, unknown>;
40
+ consumed: {
41
+ asVoice?: boolean;
42
+ audioAsVoice?: boolean;
43
+ downloadMedia?: boolean;
44
+ type?: string;
45
+ kind?: string;
46
+ replyToId?: string;
47
+ };
48
+ } {
49
+ // 1. Parse marker from text
50
+ const { cleanText, params: markerParams } = parseBncrMarker(
51
+ typeof ctx.text === 'string' ? ctx.text : '',
52
+ );
53
+
54
+ // 2. Merge all sources: extra from ctx + marker params + host-level fields
55
+ const merged: Record<string, unknown> = {
56
+ ...(typeof ctx.extra === 'object' && ctx.extra !== null ? ctx.extra : {}),
57
+ ...markerParams,
58
+ };
59
+ if (ctx.forceDocument === true) merged.forceDocument = true;
60
+ if (ctx.gifPlayback === true) merged.gifPlayback = true;
61
+ if (ctx.silent === true) merged.silent = true;
62
+
63
+ // 3. Strip consumption fields
64
+ const { consumed, remaining } = extractConsumptionFields(merged);
65
+
66
+ return {
67
+ cleanText,
68
+ extra: remaining,
69
+ consumed,
70
+ };
71
+ }
72
+
31
73
  function resolveChannelSendReplyToId(
32
74
  asString: BncrChannelSendRuntime['asString'],
33
75
  ctx: BncrChannelSendContext,
@@ -48,6 +90,7 @@ function logChannelSendEntry(
48
90
  mediaUrls?: string[];
49
91
  asVoice?: boolean;
50
92
  audioAsVoice?: boolean;
93
+ downloadMedia?: boolean;
51
94
  };
52
95
  },
53
96
  ) {
@@ -61,6 +104,7 @@ function logChannelSendEntry(
61
104
  mediaUrls: args.payload.mediaUrls,
62
105
  asVoice: args.payload.asVoice,
63
106
  audioAsVoice: args.payload.audioAsVoice,
107
+ downloadMedia: args.payload.downloadMedia,
64
108
  sessionKey: runtime.asString(args.ctx?.sessionKey || ''),
65
109
  mirrorSessionKey: runtime.asString(args.ctx?.mirror?.sessionKey || ''),
66
110
  rawCtx: {
@@ -78,7 +122,11 @@ async function enqueueChannelMessageHandoff(
78
122
  runtime: BncrChannelSendRuntime,
79
123
  ctx: BncrChannelSendContext,
80
124
  payload: ReplyPayloadInput,
125
+ extra?: Record<string, unknown>,
81
126
  ) {
127
+ if (extra && Object.keys(extra).length > 0) {
128
+ payload = { ...payload, extra: { ...extra } };
129
+ }
82
130
  const accountId = normalizeAccountId(ctx.accountId);
83
131
  const to = runtime.asString(ctx.to || '').trim();
84
132
  const verified = runtime.resolveVerifiedTarget(to, accountId);
@@ -99,128 +147,183 @@ async function enqueueChannelMessageHandoff(
99
147
  }
100
148
 
101
149
  export function createBncrChannelSendRuntime(runtime: BncrChannelSendRuntime) {
102
- return {
103
- channelSendText: async (ctx: BncrChannelSendContext) => {
104
- await runtime.syncDebugFlag();
105
- const accountId = normalizeAccountId(ctx.accountId);
106
- const to = runtime.asString(ctx.to || '').trim();
107
- const replyToId = resolveChannelSendReplyToId(runtime.asString, ctx);
108
-
109
- logChannelSendEntry(runtime, {
110
- kind: 'text',
111
- accountId,
112
- to,
113
- ctx,
114
- payload: {
115
- text: runtime.asString(ctx?.text || ''),
116
- mediaUrl: runtime.asString(ctx?.mediaUrl || ''),
117
- },
118
- });
150
+ /**
151
+ * Single dispatch: resolves unified extra (marker + ctx + host fields),
152
+ * determines text vs media route, and calls the appropriate send function.
153
+ */
154
+ async function sendDispatch(ctx: BncrChannelSendContext) {
155
+ await runtime.syncDebugFlag();
156
+ const accountId = normalizeAccountId(ctx.accountId);
157
+ const to = runtime.asString(ctx.to || '').trim();
158
+ const { cleanText, extra, consumed } = resolveUnifiedOutboundExtra(ctx);
159
+ const replyToId = resolveChannelSendReplyToId(runtime.asString, ctx);
119
160
 
120
- return sendBncrText({
121
- channelId: runtime.channelId,
122
- accountId,
123
- to,
124
- text: runtime.asString(ctx.text || ''),
125
- kind: normalizeReplyKind(ctx?.kind),
126
- replyToId,
127
- mediaLocalRoots: ctx.mediaLocalRoots,
128
- resolveVerifiedTarget: (targetTo, targetAccountId) =>
129
- runtime.resolveVerifiedTarget(targetTo, targetAccountId),
130
- rememberSessionRoute: (sessionKey, routeAccountId, route) =>
131
- runtime.rememberSessionRoute(sessionKey, routeAccountId, route),
132
- enqueueFromReply: (args) => runtime.enqueueFromReply(args),
133
- createMessageId: () => randomUUID(),
134
- });
135
- },
161
+ // Resolve effective media params: ctx.mediaUrl is the source of truth.
162
+ // extra.path/mediaUrl from marker only triggers media when:
163
+ // - it is a local file path (starts with / or ./), OR
164
+ // - consumed.type is a known media type (file/image/video/audio)
165
+ // Otherwise they are treated as metadata (e.g. appmsg thumbnail URL).
166
+ const ctxMediaUrl = runtime.asString(ctx.mediaUrl || '').trim();
167
+ const markerPath = runtime
168
+ .asString((extra.path as string) || (extra.mediaUrl as string) || '')
169
+ .trim();
170
+ const isLocalPath =
171
+ markerPath.startsWith('/') || markerPath.startsWith('./') || markerPath.startsWith('../');
172
+ const isMediaType = ['file', 'image', 'video', 'audio'].includes(consumed.type || '');
173
+ const effectiveMediaUrl = ctxMediaUrl || (isLocalPath || isMediaType ? markerPath : '');
174
+ const effectiveMediaUrls = Array.isArray(ctx?.mediaUrls) ? ctx.mediaUrls : undefined;
175
+ const asVoice = consumed.asVoice ?? ctx?.asVoice === true;
176
+ const audioAsVoice = consumed.audioAsVoice ?? ctx?.audioAsVoice === true;
177
+ const downloadMedia = consumed.downloadMedia ?? ctx?.downloadMedia ?? false;
178
+ const type = consumed.type ?? (runtime.asString(ctx?.type || '').trim() || undefined);
179
+ const hasMedia = !!(effectiveMediaUrl || effectiveMediaUrls?.length);
180
+ console.log(
181
+ '[bncr] send-dispatch cleanText=' +
182
+ JSON.stringify(cleanText) +
183
+ '|downloadMedia=' +
184
+ JSON.stringify(consumed.downloadMedia) +
185
+ '|hasMedia=' +
186
+ hasMedia +
187
+ '|mediaUrl=' +
188
+ JSON.stringify(effectiveMediaUrl),
189
+ );
136
190
 
137
- channelSendMedia: async (ctx: BncrChannelSendContext) => {
138
- await runtime.syncDebugFlag();
139
- const accountId = normalizeAccountId(ctx.accountId);
140
- const to = runtime.asString(ctx.to || '').trim();
141
- const asVoice = ctx?.asVoice === true;
142
- const audioAsVoice = ctx?.audioAsVoice === true;
143
- const type = runtime.asString(ctx?.type || '').trim() || undefined;
144
- const replyToId = resolveChannelSendReplyToId(runtime.asString, ctx);
145
-
146
- logChannelSendEntry(runtime, {
147
- kind: 'media',
148
- accountId,
149
- to,
150
- ctx,
151
- payload: {
152
- text: runtime.asString(ctx?.text || ''),
153
- mediaUrl: runtime.asString(ctx?.mediaUrl || ''),
154
- mediaUrls: Array.isArray(ctx?.mediaUrls) ? ctx.mediaUrls : undefined,
155
- asVoice,
156
- audioAsVoice,
157
- },
158
- });
191
+ const kind: 'text' | 'media' = hasMedia ? 'media' : 'text';
192
+ logChannelSendEntry(runtime, {
193
+ kind,
194
+ accountId,
195
+ to,
196
+ ctx,
197
+ payload: {
198
+ text: cleanText,
199
+ mediaUrl: effectiveMediaUrl,
200
+ mediaUrls: effectiveMediaUrls,
201
+ asVoice,
202
+ audioAsVoice,
203
+ },
204
+ });
159
205
 
206
+ if (hasMedia) {
160
207
  return sendBncrMedia({
161
208
  channelId: runtime.channelId,
162
209
  accountId,
163
210
  to,
164
- text: runtime.asString(ctx.text || ''),
165
- mediaUrl: runtime.asString(ctx.mediaUrl || ''),
166
- mediaUrls: Array.isArray(ctx?.mediaUrls) ? ctx.mediaUrls : undefined,
211
+ text: cleanText,
212
+ mediaUrl: effectiveMediaUrl,
213
+ mediaUrls: effectiveMediaUrls,
167
214
  asVoice,
168
215
  audioAsVoice,
216
+ downloadMedia,
169
217
  type,
218
+ extra: Object.keys(extra).length > 0 ? extra : undefined,
170
219
  kind: normalizeReplyKind(ctx?.kind),
171
220
  replyToId,
172
221
  mediaLocalRoots: ctx.mediaLocalRoots,
173
- resolveVerifiedTarget: (targetTo, targetAccountId) =>
174
- runtime.resolveVerifiedTarget(targetTo, targetAccountId),
175
- rememberSessionRoute: (sessionKey, routeAccountId, route) =>
176
- runtime.rememberSessionRoute(sessionKey, routeAccountId, route),
177
- enqueueFromReply: (args) => runtime.enqueueFromReply(args),
222
+ resolveVerifiedTarget: (...a) => runtime.resolveVerifiedTarget(...a),
223
+ rememberSessionRoute: (...a) => runtime.rememberSessionRoute(...a),
224
+ enqueueFromReply: (a) => runtime.enqueueFromReply(a),
178
225
  createMessageId: () => randomUUID(),
179
226
  });
180
- },
227
+ }
181
228
 
182
- channelMessageSendText: async (ctx: BncrChannelSendContext) => {
183
- const entry = await enqueueChannelMessageHandoff(runtime, ctx, {
184
- text: runtime.asString(ctx.text || ''),
185
- kind: normalizeReplyKind(ctx?.kind),
186
- replyToId: resolveChannelSendReplyToId(runtime.asString, ctx),
187
- });
188
- return buildBncrDurableQueuedResult({ entry });
229
+ return sendBncrText({
230
+ channelId: runtime.channelId,
231
+ accountId,
232
+ to,
233
+ text: cleanText,
234
+ kind: normalizeReplyKind(ctx?.kind),
235
+ replyToId,
236
+ extra: Object.keys(extra).length > 0 ? extra : undefined,
237
+ mediaLocalRoots: ctx.mediaLocalRoots,
238
+ resolveVerifiedTarget: (...a) => runtime.resolveVerifiedTarget(...a),
239
+ rememberSessionRoute: (...a) => runtime.rememberSessionRoute(...a),
240
+ enqueueFromReply: (a) => runtime.enqueueFromReply(a),
241
+ createMessageId: () => randomUUID(),
242
+ });
243
+ }
244
+
245
+ /**
246
+ * Durable dispatch: same logic but enqueues via channel message handoff.
247
+ */
248
+ async function messageSendDispatch(
249
+ ctx: BncrChannelSendContext,
250
+ inputOverrides?: {
251
+ text?: string;
252
+ mediaUrl?: string;
253
+ mediaUrls?: string[];
254
+ asVoice?: boolean;
255
+ audioAsVoice?: boolean;
256
+ downloadMedia?: boolean;
257
+ type?: string;
258
+ kind?: ReplyPayloadInput['kind'];
259
+ replyToId?: string;
189
260
  },
261
+ ) {
262
+ // Merge inputOverrides text into ctx so marker parsing sees the effective text
263
+ const effectiveCtx =
264
+ inputOverrides?.text !== undefined ? { ...ctx, text: inputOverrides.text } : ctx;
265
+ const { cleanText, extra, consumed } = resolveUnifiedOutboundExtra(effectiveCtx);
266
+ const replyToId = resolveChannelSendReplyToId(runtime.asString, ctx);
267
+
268
+ console.log(
269
+ '[bncr] msg-dispatch cleanText=' +
270
+ JSON.stringify(cleanText) +
271
+ '|downloadMedia=' +
272
+ JSON.stringify(consumed.downloadMedia) +
273
+ '|mediaUrl=' +
274
+ JSON.stringify(inputOverrides?.mediaUrl),
275
+ );
276
+
277
+ const payload: ReplyPayloadInput = {
278
+ text: cleanText,
279
+ mediaUrl: inputOverrides?.mediaUrl || '',
280
+ mediaUrls: inputOverrides?.mediaUrls,
281
+ asVoice: consumed.asVoice ?? inputOverrides?.asVoice ?? false,
282
+ audioAsVoice: consumed.audioAsVoice ?? inputOverrides?.audioAsVoice ?? false,
283
+ downloadMedia: consumed.downloadMedia ?? inputOverrides?.downloadMedia ?? false,
284
+ type: consumed.type ?? inputOverrides?.type,
285
+ kind: normalizeReplyKind(consumed.kind) ?? inputOverrides?.kind,
286
+ replyToId: inputOverrides?.replyToId || replyToId,
287
+ ...(Object.keys(extra).length > 0 ? { extra: { ...extra } } : {}),
288
+ };
289
+ const entry = await enqueueChannelMessageHandoff(runtime, ctx, payload, extra);
290
+ return buildBncrDurableQueuedResult({ entry });
291
+ }
292
+
293
+ return {
294
+ channelSendText: sendDispatch,
295
+ channelSendMedia: sendDispatch,
190
296
 
191
- channelMessageSendMedia: async (ctx: BncrChannelSendContext) => {
192
- const entry = await enqueueChannelMessageHandoff(runtime, ctx, {
193
- text: runtime.asString(ctx.text || ''),
297
+ channelMessageSendText: async (ctx: BncrChannelSendContext) =>
298
+ messageSendDispatch(ctx, { kind: normalizeReplyKind(ctx?.kind) }),
299
+
300
+ channelMessageSendMedia: async (ctx: BncrChannelSendContext) =>
301
+ messageSendDispatch(ctx, {
194
302
  mediaUrl: runtime.asString(ctx.mediaUrl || ''),
195
303
  mediaUrls: Array.isArray(ctx?.mediaUrls) ? ctx.mediaUrls : undefined,
196
304
  asVoice: ctx?.asVoice === true,
197
305
  audioAsVoice: ctx?.audioAsVoice === true,
198
- type: runtime.asString(ctx?.type || '').trim() || undefined,
306
+ type: runtime.asString(ctx.type || '').trim() || undefined,
199
307
  kind: normalizeReplyKind(ctx?.kind),
200
- replyToId: resolveChannelSendReplyToId(runtime.asString, ctx),
201
- });
202
- return buildBncrDurableQueuedResult({ entry });
203
- },
308
+ }),
204
309
 
205
310
  channelMessageSendPayload: async (ctx: BncrChannelSendContext) => {
206
- const payload = ctx?.payload || {};
207
- if (!payload || typeof payload !== 'object') {
311
+ const p = ctx?.payload || {};
312
+ if (!p || typeof p !== 'object')
208
313
  throw new Error('bncr channel.message payload must be an object');
209
- }
210
- const entry = await enqueueChannelMessageHandoff(runtime, ctx, {
211
- text: runtime.asString(payload.text || payload.message || payload.caption || ''),
212
- mediaUrl: runtime.asString(payload.mediaUrl || ''),
213
- mediaUrls: Array.isArray(payload.mediaUrls) ? payload.mediaUrls : undefined,
214
- asVoice: payload.asVoice === true,
215
- audioAsVoice: payload.audioAsVoice === true,
216
- type: runtime.asString(payload.type || '').trim() || undefined,
217
- kind: normalizeReplyKind(payload.kind),
314
+ return messageSendDispatch(ctx, {
315
+ text: runtime.asString(p.text || p.message || p.caption || ''),
316
+ mediaUrl: runtime.asString(p.mediaUrl || ''),
317
+ mediaUrls: Array.isArray(p.mediaUrls) ? p.mediaUrls : undefined,
318
+ asVoice: p.asVoice === true,
319
+ audioAsVoice: p.audioAsVoice === true,
320
+ downloadMedia: p.downloadMedia === true,
321
+ type: runtime.asString(p.type || '').trim() || undefined,
322
+ kind: normalizeReplyKind(p.kind) ?? normalizeReplyKind(ctx?.kind),
218
323
  replyToId:
219
- runtime
220
- .asString(payload.replyToId || ctx?.replyToId || ctx?.replyToMessageId || '')
221
- .trim() || undefined,
324
+ runtime.asString(p.replyToId || ctx?.replyToId || ctx?.replyToMessageId || '').trim() ||
325
+ undefined,
222
326
  });
223
- return buildBncrDurableQueuedResult({ entry });
224
327
  },
225
328
  };
226
329
  }
@@ -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
 
@@ -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;
@@ -24,6 +24,10 @@ type BncrOutboundSendContext = {
24
24
  asVoice?: boolean;
25
25
  audioAsVoice?: boolean;
26
26
  mediaLocalRoots?: readonly string[];
27
+ /** Pass-through fields from host — bncr plugin does NOT consume these. */
28
+ forceDocument?: boolean;
29
+ gifPlayback?: boolean;
30
+ silent?: boolean;
27
31
  };
28
32
 
29
33
  type BncrOutboundReplyActionContext = {