@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.
- package/package.json +1 -1
- package/src/channel.ts +48 -7
- package/src/core/outbox-entry-builders.ts +7 -0
- package/src/messaging/inbound/context-facts.ts +5 -4
- package/src/messaging/inbound/group-history.ts +4 -1
- package/src/messaging/inbound/native-command.ts +7 -3
- package/src/messaging/inbound/scene-admin.ts +181 -5
- package/src/messaging/inbound/turn-context.ts +16 -0
- package/src/messaging/outbound/marker-parser.ts +154 -0
- package/src/messaging/outbound/media.ts +5 -14
- package/src/messaging/outbound/reply-enqueue-media.ts +4 -0
- package/src/messaging/outbound/reply-enqueue.ts +25 -3
- package/src/messaging/outbound/send-params.ts +43 -10
- package/src/messaging/outbound/send.ts +6 -0
- package/src/plugin/bridge-file-transfer-push-facade.ts +3 -1
- package/src/plugin/bridge-media-facade.ts +2 -1
- package/src/plugin/channel-plugin-surface-group.ts +30 -0
- package/src/plugin/channel-runtime-builders-delivery.ts +2 -0
- package/src/plugin/channel-runtime-types.ts +8 -0
- package/src/plugin/channel-send.ts +197 -94
- package/src/plugin/file-transfer-orchestrator.ts +105 -3
- package/src/plugin/file-transfer-setup.ts +1 -1
- package/src/plugin/media-orchestrators-runtime-group.ts +2 -0
- package/src/plugin/outbound.ts +4 -0
|
@@ -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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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:
|
|
165
|
-
mediaUrl:
|
|
166
|
-
mediaUrls:
|
|
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: (
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
306
|
+
type: runtime.asString(ctx.type || '').trim() || undefined,
|
|
199
307
|
kind: normalizeReplyKind(ctx?.kind),
|
|
200
|
-
|
|
201
|
-
});
|
|
202
|
-
return buildBncrDurableQueuedResult({ entry });
|
|
203
|
-
},
|
|
308
|
+
}),
|
|
204
309
|
|
|
205
310
|
channelMessageSendPayload: async (ctx: BncrChannelSendContext) => {
|
|
206
|
-
const
|
|
207
|
-
if (!
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
type: runtime.asString(
|
|
217
|
-
kind: normalizeReplyKind(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -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;
|
package/src/plugin/outbound.ts
CHANGED
|
@@ -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 = {
|