@aspalagin/openclaw-max 0.7.3 → 0.8.1
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/LICENSE +2 -0
- package/README.md +412 -344
- package/README_EN.md +492 -0
- package/SKILL.md +45 -37
- package/assets/icon.png +0 -0
- package/dist/secret-contract-api.js +6 -0
- package/dist/src/access-policy.js +123 -0
- package/dist/src/accounts.js +170 -23
- package/dist/src/action-scope.js +213 -0
- package/dist/src/actions.js +159 -31
- package/dist/src/api.js +99 -83
- package/dist/src/callbacks.js +27 -7
- package/dist/src/channel-agent-prompt.js +8 -19
- package/dist/src/channel-config.js +25 -3
- package/dist/src/channel-lifecycle.js +28 -5
- package/dist/src/channel-outbound.js +43 -9
- package/dist/src/channel-policy.js +19 -6
- package/dist/src/channel.js +16 -0
- package/dist/src/command-menu.js +199 -0
- package/dist/src/config-schema.js +106 -10
- package/dist/src/deliver.js +144 -48
- package/dist/src/dispatch.js +12 -11
- package/dist/src/gateway-drain.js +40 -0
- package/dist/src/group-mention.js +129 -0
- package/dist/src/inbound-attachments.js +162 -20
- package/dist/src/inbound.js +388 -134
- package/dist/src/media-temp.js +31 -20
- package/dist/src/network.js +216 -0
- package/dist/src/onboarding.js +4 -2
- package/dist/src/polling.js +180 -12
- package/dist/src/presentation.js +11 -0
- package/dist/src/progress-draft.js +168 -0
- package/dist/src/runtime.js +15 -0
- package/dist/src/secret-contract.js +149 -0
- package/dist/src/send.js +250 -46
- package/dist/src/stream-draft.js +83 -41
- package/dist/src/turn-adoption.js +24 -0
- package/dist/src/typing.js +55 -0
- package/dist/src/update-journal.js +267 -0
- package/dist/src/webhook-queue.js +151 -26
- package/dist/src/webhook-runner.js +61 -9
- package/dist/src/webhook.js +34 -15
- package/openclaw.plugin.json +983 -8
- package/package.json +17 -4
- package/src/access-policy.ts +154 -0
- package/src/accounts.ts +260 -25
- package/src/action-scope.ts +274 -0
- package/src/actions.ts +188 -35
- package/src/api.ts +158 -101
- package/src/callbacks.ts +26 -7
- package/src/channel-agent-prompt.ts +8 -19
- package/src/channel-config.ts +32 -3
- package/src/channel-lifecycle.ts +33 -6
- package/src/channel-outbound.ts +50 -7
- package/src/channel-policy.ts +27 -7
- package/src/channel.ts +16 -0
- package/src/command-menu.ts +277 -0
- package/src/config-schema.ts +111 -10
- package/src/deliver.ts +174 -55
- package/src/dispatch.ts +12 -11
- package/src/gateway-drain.ts +45 -0
- package/src/group-mention.ts +191 -0
- package/src/inbound-attachments.ts +173 -22
- package/src/inbound.ts +443 -130
- package/src/media-temp.ts +35 -23
- package/src/network.ts +233 -0
- package/src/onboarding.ts +4 -2
- package/src/polling.ts +221 -15
- package/src/presentation.ts +14 -1
- package/src/progress-draft.ts +226 -0
- package/src/runtime.ts +23 -0
- package/src/secret-contract.ts +183 -0
- package/src/send.ts +346 -59
- package/src/stream-draft.ts +102 -41
- package/src/turn-adoption.ts +33 -0
- package/src/types.ts +9 -3
- package/src/typing.ts +77 -0
- package/src/update-journal.ts +302 -0
- package/src/webhook-queue.ts +205 -33
- package/src/webhook-runner.ts +65 -8
- package/src/webhook.ts +39 -18
- package/scripts/check-cycles.mjs +0 -38
- package/scripts/test-api.mjs +0 -169
- package/scripts/test-send.mjs +0 -162
- package/scripts/update-schema.mjs +0 -48
package/src/send.ts
CHANGED
|
@@ -3,7 +3,16 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { OpenClawConfig } from 'openclaw/plugin-sdk/core';
|
|
6
|
+
import {
|
|
7
|
+
buildOutboundMediaLoadOptions,
|
|
8
|
+
type OutboundMediaAccess,
|
|
9
|
+
} from 'openclaw/plugin-sdk/media-runtime';
|
|
6
10
|
import { retryAsync } from 'openclaw/plugin-sdk/runtime-env';
|
|
11
|
+
import {
|
|
12
|
+
isBlockedHostnameOrIp,
|
|
13
|
+
resolvePinnedHostnameWithPolicy,
|
|
14
|
+
} from 'openclaw/plugin-sdk/ssrf-runtime';
|
|
15
|
+
import { loadWebMediaRaw } from 'openclaw/plugin-sdk/web-media';
|
|
7
16
|
|
|
8
17
|
import { resolveMaxAccount } from './accounts.js';
|
|
9
18
|
import {
|
|
@@ -18,7 +27,8 @@ import {
|
|
|
18
27
|
type MaxStickerAttachment,
|
|
19
28
|
} from './api.js';
|
|
20
29
|
import { toMaxMarkdown } from './format.js';
|
|
21
|
-
import {
|
|
30
|
+
import { downloadMaxRemoteMedia, type MaxLoadedMedia, sanitizeMaxFileName } from './media-temp.js';
|
|
31
|
+
import { getMaxLogger } from './runtime.js';
|
|
22
32
|
|
|
23
33
|
export type MaxSendButton = {
|
|
24
34
|
text: string;
|
|
@@ -231,6 +241,32 @@ type MaxBodyAttachments = NonNullable<MaxNewMessageBody['attachments']>;
|
|
|
231
241
|
* given (attachment-only bodies carry neither), notify, attachments and the
|
|
232
242
|
* reply link.
|
|
233
243
|
*/
|
|
244
|
+
/** Account defaults for notifications and link previews (config `notify`, `disableLinkPreview`). */
|
|
245
|
+
export interface MaxSendDefaults {
|
|
246
|
+
notify?: boolean;
|
|
247
|
+
disableLinkPreview?: boolean;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Notification and link-preview options of one send. An explicit value of the
|
|
252
|
+
* call beats the account default: core's `silent` flag first, then
|
|
253
|
+
* channelData.max (`notify`, `silent`, `disableLinkPreview`). Unset fields stay
|
|
254
|
+
* unset, so MAX applies its own defaults (notify on, previews on).
|
|
255
|
+
*/
|
|
256
|
+
export function resolveMaxSendFlags(
|
|
257
|
+
defaults: MaxSendDefaults,
|
|
258
|
+
explicit: { silent?: boolean; channelData?: unknown } = {},
|
|
259
|
+
): { notify?: boolean; disableLinkPreview?: boolean } {
|
|
260
|
+
const fromData = readMaxChannelSendOptions(explicit.channelData);
|
|
261
|
+
const notify =
|
|
262
|
+
typeof explicit.silent === 'boolean' ? !explicit.silent : (fromData.notify ?? defaults.notify);
|
|
263
|
+
const disableLinkPreview = fromData.disableLinkPreview ?? defaults.disableLinkPreview;
|
|
264
|
+
return {
|
|
265
|
+
...(notify !== undefined ? { notify } : {}),
|
|
266
|
+
...(disableLinkPreview !== undefined ? { disableLinkPreview } : {}),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
234
270
|
function buildMaxBody(
|
|
235
271
|
opts: MaxSendOptions,
|
|
236
272
|
text: string | undefined,
|
|
@@ -280,7 +316,7 @@ export type MaxSendOutcome = { messageId: string; raw: MaxSendResult };
|
|
|
280
316
|
* the body (after the target, so a bad target fails before any upload), send —
|
|
281
317
|
* the per-chat limiter lives in MaxApi.sendMessage — through the caller's retry
|
|
282
318
|
* policy, and report the sent mid. typing_on is not sent here: the inbound
|
|
283
|
-
* pipeline
|
|
319
|
+
* pipeline keeps it up for the agent turn.
|
|
284
320
|
*/
|
|
285
321
|
async function sendWithBody(params: {
|
|
286
322
|
api: MaxApi;
|
|
@@ -291,7 +327,12 @@ async function sendWithBody(params: {
|
|
|
291
327
|
}): Promise<MaxSendOutcome> {
|
|
292
328
|
const { api, to, opts, retry } = params;
|
|
293
329
|
const target = await resolveMaxTarget(api, to);
|
|
294
|
-
|
|
330
|
+
let body = typeof params.body === 'function' ? await params.body() : params.body;
|
|
331
|
+
// MAX channels publish only with notifications (POST /messages: notify must
|
|
332
|
+
// be true or absent for a channel), so a silent send into a channel notifies.
|
|
333
|
+
if (body.notify === false && 'chat_id' in target && (await isMaxChannel(api, target.chat_id))) {
|
|
334
|
+
body = { ...body, notify: undefined };
|
|
335
|
+
}
|
|
295
336
|
const sendTo = (to: MaxSendTarget) => {
|
|
296
337
|
const sendParams = buildSendParams(to, opts);
|
|
297
338
|
const send = () => api.sendMessage(body, sendParams);
|
|
@@ -314,6 +355,30 @@ async function sendWithBody(params: {
|
|
|
314
355
|
};
|
|
315
356
|
}
|
|
316
357
|
|
|
358
|
+
/** Chat id → is a channel. A chat never changes its type, so this is never invalidated. */
|
|
359
|
+
const channelChats = new Map<number, boolean>();
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Whether chat_id names a MAX channel (GET /chats/{chatId}, cached). A chat
|
|
363
|
+
* the bot does not know is not a channel (the send falls back to user_id);
|
|
364
|
+
* after any other lookup failure assume a channel: a notified post is better
|
|
365
|
+
* than a send MAX rejects.
|
|
366
|
+
*/
|
|
367
|
+
async function isMaxChannel(api: MaxApi, chatId: number): Promise<boolean> {
|
|
368
|
+
const cached = channelChats.get(chatId);
|
|
369
|
+
if (cached !== undefined) return cached;
|
|
370
|
+
let isChannel: boolean;
|
|
371
|
+
try {
|
|
372
|
+
isChannel = (await api.getChat(chatId)).type === 'channel';
|
|
373
|
+
} catch (err) {
|
|
374
|
+
if (!isChatNotFound(err)) return true;
|
|
375
|
+
isChannel = false;
|
|
376
|
+
}
|
|
377
|
+
if (channelChats.size >= 1000) channelChats.clear();
|
|
378
|
+
channelChats.set(chatId, isChannel);
|
|
379
|
+
return isChannel;
|
|
380
|
+
}
|
|
381
|
+
|
|
317
382
|
/** MAX answer for a chat_id that names no chat of the bot (e.g. a user id). */
|
|
318
383
|
export function isChatNotFound(err: unknown): boolean {
|
|
319
384
|
if (!(err instanceof MaxApiError)) return false;
|
|
@@ -333,7 +398,7 @@ async function retryOnceOnTimeout(send: () => Promise<MaxSendResult>): Promise<M
|
|
|
333
398
|
return await send();
|
|
334
399
|
} catch (err) {
|
|
335
400
|
if (!(err instanceof MaxRequestTimeoutError)) throw err;
|
|
336
|
-
|
|
401
|
+
getMaxLogger().error(
|
|
337
402
|
`[MAX] send timed out after ${err.elapsedMs}ms (phase=${err.phase}); retrying once`,
|
|
338
403
|
);
|
|
339
404
|
return send();
|
|
@@ -352,7 +417,39 @@ function retryWhileAttachmentNotReady(send: () => Promise<MaxSendResult>): Promi
|
|
|
352
417
|
}
|
|
353
418
|
|
|
354
419
|
/**
|
|
355
|
-
*
|
|
420
|
+
* MAX refused a formatted text request as a whole (400) for a reason other
|
|
421
|
+
* than the target, an unready attachment or a dialog-only restriction, so the
|
|
422
|
+
* likely cause is the markup. MAX documents no dedicated code for bad markup:
|
|
423
|
+
* the known unrelated refusals are excluded instead. A 400 means nothing was
|
|
424
|
+
* created, so one plain-text resend cannot duplicate the message.
|
|
425
|
+
*/
|
|
426
|
+
export function isMaxFormatRejection(err: unknown): boolean {
|
|
427
|
+
if (!(err instanceof MaxApiError) || err.status !== 400) return false;
|
|
428
|
+
return !isChatNotFound(err) && !isAttachmentNotReady(err) && !isPinUnavailableForDialog(err);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Run a formatted text request; when MAX refuses the markup, repeat it once
|
|
433
|
+
* without `format` (the text goes as written). The second failure propagates.
|
|
434
|
+
*/
|
|
435
|
+
async function withPlainTextFallback<T>(
|
|
436
|
+
text: string,
|
|
437
|
+
opts: MaxSendOptions,
|
|
438
|
+
attempt: (opts: MaxSendOptions) => Promise<T>,
|
|
439
|
+
): Promise<T> {
|
|
440
|
+
try {
|
|
441
|
+
return await attempt(opts);
|
|
442
|
+
} catch (err) {
|
|
443
|
+
if (!opts.format || !text.trim() || !isMaxFormatRejection(err)) throw err;
|
|
444
|
+
const code = err instanceof MaxApiError ? (err.code ?? err.status) : '';
|
|
445
|
+
getMaxLogger().warn(`[MAX] ${opts.format} refused (${code}); resending as plain text`);
|
|
446
|
+
return attempt({ ...opts, format: undefined });
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Send a text message to a MAX chat or user. A markup refusal is retried
|
|
452
|
+
* once as plain text.
|
|
356
453
|
*/
|
|
357
454
|
export async function sendMaxMessage(
|
|
358
455
|
to: string,
|
|
@@ -360,13 +457,15 @@ export async function sendMaxMessage(
|
|
|
360
457
|
opts: MaxSendOptions = {},
|
|
361
458
|
): Promise<MaxSendOutcome> {
|
|
362
459
|
const api = new MaxApi({ token: resolveToken(opts) });
|
|
363
|
-
return
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
460
|
+
return withPlainTextFallback(text, opts, (sendOpts) =>
|
|
461
|
+
sendWithBody({
|
|
462
|
+
api,
|
|
463
|
+
to,
|
|
464
|
+
opts: sendOpts,
|
|
465
|
+
body: buildMaxTextBody(text, sendOpts),
|
|
466
|
+
retry: retryOnceOnTimeout,
|
|
467
|
+
}),
|
|
468
|
+
);
|
|
370
469
|
}
|
|
371
470
|
|
|
372
471
|
/**
|
|
@@ -380,10 +479,11 @@ export async function answerMaxCallback(
|
|
|
380
479
|
): Promise<void> {
|
|
381
480
|
const token = resolveToken(opts);
|
|
382
481
|
const api = new MaxApi({ token });
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
482
|
+
await withPlainTextFallback(text, opts, async (answerOpts) => {
|
|
483
|
+
await api.answerCallback(callbackId, {
|
|
484
|
+
...(text || opts.buttons?.length ? { message: buildMaxTextBody(text, answerOpts) } : {}),
|
|
485
|
+
...(opts.notification ? { notification: opts.notification } : {}),
|
|
486
|
+
});
|
|
387
487
|
});
|
|
388
488
|
}
|
|
389
489
|
|
|
@@ -391,7 +491,8 @@ export async function answerMaxCallback(
|
|
|
391
491
|
* Edit an existing MAX message. PUT /messages keeps the current attachments
|
|
392
492
|
* when `attachments` is absent/null and deletes them all on an empty list, so
|
|
393
493
|
* the keyboard is sent only when `buttons` are given (it then replaces the
|
|
394
|
-
* message attachments, e.g. adds buttons to a text-only stream draft).
|
|
494
|
+
* message attachments, e.g. adds buttons to a text-only stream draft). A
|
|
495
|
+
* markup refusal is retried once as plain text.
|
|
395
496
|
*/
|
|
396
497
|
export async function editMaxMessage(
|
|
397
498
|
messageId: string,
|
|
@@ -401,10 +502,12 @@ export async function editMaxMessage(
|
|
|
401
502
|
const token = resolveToken(opts);
|
|
402
503
|
const api = new MaxApi({ token });
|
|
403
504
|
|
|
404
|
-
await
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
505
|
+
await withPlainTextFallback(text, opts, async (editOpts) => {
|
|
506
|
+
await api.editMessage(messageId, {
|
|
507
|
+
text: formatOutboundText(text, editOpts.format),
|
|
508
|
+
format: editOpts.format ?? undefined,
|
|
509
|
+
...(opts.buttons?.length ? { attachments: [buildInlineKeyboard(opts.buttons)] } : {}),
|
|
510
|
+
});
|
|
408
511
|
});
|
|
409
512
|
}
|
|
410
513
|
|
|
@@ -516,13 +619,57 @@ export const MAX_VISUAL_MEDIA_PER_MESSAGE = 12;
|
|
|
516
619
|
|
|
517
620
|
const DEFAULT_MEDIA_MAX_BYTES = 20 * 1024 * 1024;
|
|
518
621
|
|
|
622
|
+
/**
|
|
623
|
+
* Host media access for local files, as core hands it to message actions and
|
|
624
|
+
* the outbound adapter (ChannelMessageActionContext / ChannelOutboundContext).
|
|
625
|
+
*/
|
|
626
|
+
export interface MaxLocalMediaAccess {
|
|
627
|
+
mediaAccess?: OutboundMediaAccess;
|
|
628
|
+
mediaLocalRoots?: readonly string[];
|
|
629
|
+
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
|
630
|
+
}
|
|
631
|
+
|
|
519
632
|
export interface MaxMediaSendOptions extends MaxSendOptions {
|
|
520
|
-
/**
|
|
633
|
+
/** Size cap for media that has to be uploaded (default 20 MB). */
|
|
521
634
|
mediaMaxBytes?: number;
|
|
635
|
+
/**
|
|
636
|
+
* Allowed roots and host reader for local paths. A local path is read only
|
|
637
|
+
* through core's guarded loader under these roots (realpath containment, so
|
|
638
|
+
* `..` and symlinks cannot escape); without them the gateway's default
|
|
639
|
+
* media roots apply.
|
|
640
|
+
*/
|
|
641
|
+
localMedia?: MaxLocalMediaAccess;
|
|
642
|
+
/**
|
|
643
|
+
* Voice delivery (reply `audioAsVoice`, message tool `asVoice`): a single
|
|
644
|
+
* audio source goes as MAX's audio attachment even when only its MIME type
|
|
645
|
+
* says audio. MAX has no separate voice type: its clients show that
|
|
646
|
+
* attachment as a voice message.
|
|
647
|
+
*/
|
|
648
|
+
asVoice?: boolean;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Media handed over as content (message tool `buffer`), not as a path or URL:
|
|
653
|
+
* the name and type come from the call; the bytes never touch the filesystem.
|
|
654
|
+
*/
|
|
655
|
+
export interface MaxInlineMedia {
|
|
656
|
+
buffer: Buffer;
|
|
657
|
+
/** Sanitized file name (see sanitizeMaxFileName); its extension picks the MAX type. */
|
|
658
|
+
fileName: string;
|
|
659
|
+
contentType?: string;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** A local path (guarded loader), a URL (guarded download) or inline content. */
|
|
663
|
+
export type MaxMediaSource = string | MaxInlineMedia;
|
|
664
|
+
|
|
665
|
+
/** Readable label of a source for errors and logs (never the content). */
|
|
666
|
+
export function describeMaxMediaSource(source: MaxMediaSource): string {
|
|
667
|
+
return typeof source === 'string' ? source : `${source.fileName} (inline)`;
|
|
522
668
|
}
|
|
523
669
|
|
|
670
|
+
/** Any case: core's local-file loader would fetch such a URL itself, past the proxy. */
|
|
524
671
|
function isRemoteUrl(source: string): boolean {
|
|
525
|
-
return
|
|
672
|
+
return /^https?:\/\//i.test(source);
|
|
526
673
|
}
|
|
527
674
|
|
|
528
675
|
/** https link whose path looks like an image: MAX can fetch it itself (image.payload.url). */
|
|
@@ -535,7 +682,26 @@ function isImageLink(source: string): boolean {
|
|
|
535
682
|
}
|
|
536
683
|
}
|
|
537
684
|
|
|
538
|
-
|
|
685
|
+
/**
|
|
686
|
+
* An image link handed to MAX by URL must point to a public host: not a
|
|
687
|
+
* private, loopback, link-local or metadata address, also after DNS
|
|
688
|
+
* resolution. Anything else is downloaded through the guarded fetcher (which
|
|
689
|
+
* refuses such hosts as well) and uploaded.
|
|
690
|
+
*/
|
|
691
|
+
async function isPublicImageLink(source: MaxMediaSource): Promise<boolean> {
|
|
692
|
+
if (typeof source !== 'string' || !isImageLink(source)) return false;
|
|
693
|
+
try {
|
|
694
|
+
const { hostname } = new URL(source);
|
|
695
|
+
if (isBlockedHostnameOrIp(hostname)) return false;
|
|
696
|
+
await resolvePinnedHostnameWithPolicy(hostname);
|
|
697
|
+
return true;
|
|
698
|
+
} catch {
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function mediaKind(source: MaxMediaSource): 'image' | 'video' | 'audio' | 'file' {
|
|
704
|
+
if (typeof source !== 'string') return detectMaxMediaType(source.fileName);
|
|
539
705
|
if (!isRemoteUrl(source)) return detectMaxMediaType(source);
|
|
540
706
|
try {
|
|
541
707
|
return detectMaxMediaType(new URL(source).pathname);
|
|
@@ -544,50 +710,158 @@ function mediaKind(source: string): 'image' | 'video' | 'audio' | 'file' {
|
|
|
544
710
|
}
|
|
545
711
|
}
|
|
546
712
|
|
|
547
|
-
/**
|
|
548
|
-
|
|
549
|
-
|
|
713
|
+
/**
|
|
714
|
+
* A local media file through core's guarded loader (loadWebMediaRaw): only
|
|
715
|
+
* under the allowed roots, through the host reader when core passed one,
|
|
716
|
+
* capped at the media size limit. A path outside the roots fails with
|
|
717
|
+
* LocalMediaAccessError ("Local media path is not under an allowed
|
|
718
|
+
* directory") before any byte is read.
|
|
719
|
+
*/
|
|
720
|
+
async function loadLocalMaxMedia(
|
|
550
721
|
source: string,
|
|
551
722
|
opts: MaxMediaSendOptions,
|
|
723
|
+
): Promise<MaxLoadedMedia> {
|
|
724
|
+
const loaded = await loadWebMediaRaw(
|
|
725
|
+
source,
|
|
726
|
+
buildOutboundMediaLoadOptions({
|
|
727
|
+
maxBytes: opts.mediaMaxBytes ?? DEFAULT_MEDIA_MAX_BYTES,
|
|
728
|
+
mediaAccess: opts.localMedia?.mediaAccess,
|
|
729
|
+
mediaLocalRoots: opts.localMedia?.mediaLocalRoots,
|
|
730
|
+
mediaReadFile: opts.localMedia?.mediaReadFile,
|
|
731
|
+
optimizeImages: false,
|
|
732
|
+
}),
|
|
733
|
+
);
|
|
734
|
+
return {
|
|
735
|
+
buffer: loaded.buffer,
|
|
736
|
+
contentType: loaded.contentType,
|
|
737
|
+
fileName: sanitizeMaxFileName(loaded.fileName, loaded.contentType),
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Inline content within the media size limit, as an upload-ready file. The
|
|
743
|
+
* caller decodes it; the limit is checked again here for every path in.
|
|
744
|
+
*/
|
|
745
|
+
function loadInlineMaxMedia(source: MaxInlineMedia, opts: MaxMediaSendOptions): MaxLoadedMedia {
|
|
746
|
+
const maxBytes = opts.mediaMaxBytes ?? DEFAULT_MEDIA_MAX_BYTES;
|
|
747
|
+
if (source.buffer.byteLength > maxBytes) {
|
|
748
|
+
throw new Error(
|
|
749
|
+
`Inline media ${source.fileName} is ${source.buffer.byteLength} bytes, over the ${maxBytes}-byte limit (mediaMaxMb)`,
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
return {
|
|
753
|
+
buffer: source.buffer,
|
|
754
|
+
contentType: source.contentType,
|
|
755
|
+
fileName: sanitizeMaxFileName(source.fileName, source.contentType),
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Load a local path (guarded loader), a remote URL (SSRF-guarded download)
|
|
761
|
+
* or inline content (size-checked).
|
|
762
|
+
*/
|
|
763
|
+
async function loadMaxMediaSource(
|
|
764
|
+
api: MaxApi,
|
|
765
|
+
source: MaxMediaSource,
|
|
766
|
+
opts: MaxMediaSendOptions,
|
|
767
|
+
): Promise<MaxLoadedMedia> {
|
|
768
|
+
return typeof source !== 'string'
|
|
769
|
+
? loadInlineMaxMedia(source, opts)
|
|
770
|
+
: isRemoteUrl(source)
|
|
771
|
+
? await downloadMaxRemoteMedia(
|
|
772
|
+
source,
|
|
773
|
+
opts.mediaMaxBytes ?? DEFAULT_MEDIA_MAX_BYTES,
|
|
774
|
+
api.mediaProxyUrl(),
|
|
775
|
+
)
|
|
776
|
+
: await loadLocalMaxMedia(source, opts);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
type MaxUploadType = ReturnType<typeof detectMaxMediaType>;
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Upload type by file name; a voice send also takes audio known only by its
|
|
783
|
+
* MIME type (.oga, audio/webm) as audio.
|
|
784
|
+
*/
|
|
785
|
+
function maxUploadType(media: MaxLoadedMedia, asVoice: boolean): MaxUploadType {
|
|
786
|
+
const type = detectMaxMediaType(media.fileName);
|
|
787
|
+
if (asVoice && media.contentType?.toLowerCase().startsWith('audio/')) return 'audio';
|
|
788
|
+
return type;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Upload loaded media. MAX may refuse an audio upload (a format it does not
|
|
793
|
+
* take as audio): nothing was sent yet, so the same bytes go as a file.
|
|
794
|
+
*/
|
|
795
|
+
async function uploadMaxAttachment(
|
|
796
|
+
api: MaxApi,
|
|
797
|
+
media: MaxLoadedMedia,
|
|
798
|
+
type: MaxUploadType,
|
|
552
799
|
): Promise<MaxAttachment> {
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
return { type, payload: { token: uploaded.token } };
|
|
561
|
-
},
|
|
800
|
+
try {
|
|
801
|
+
const uploaded = await api.uploadMedia(type, media.buffer, media.contentType, media.fileName);
|
|
802
|
+
return { type, payload: { token: uploaded.token } };
|
|
803
|
+
} catch (err) {
|
|
804
|
+
if (type !== 'audio' || !(err instanceof MaxApiError)) throw err;
|
|
805
|
+
getMaxLogger().warn(
|
|
806
|
+
`[MAX] audio upload of ${media.fileName} refused (${err.code ?? err.status}); sending as a file`,
|
|
562
807
|
);
|
|
808
|
+
return uploadMaxAttachment(api, media, 'file');
|
|
563
809
|
}
|
|
564
|
-
const type = detectMaxMediaType(source);
|
|
565
|
-
const uploaded = await api.uploadMedia(type, source);
|
|
566
|
-
return { type, payload: { token: uploaded.token } };
|
|
567
810
|
}
|
|
568
811
|
|
|
569
812
|
/**
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
|
|
574
|
-
|
|
813
|
+
* MAX refused a message with an audio attachment (400, also an audio still
|
|
814
|
+
* not processed after the attachment.not.ready retries): nothing was created,
|
|
815
|
+
* so one resend with the audio as a file cannot duplicate.
|
|
816
|
+
*/
|
|
817
|
+
function isMaxAudioRefusal(err: unknown): boolean {
|
|
818
|
+
return err instanceof MaxApiError && err.status === 400 && !isChatNotFound(err);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* One message with the given media. Image links to public https hosts go as
|
|
823
|
+
* image.payload.url (no download/upload round trip); if MAX refuses the
|
|
824
|
+
* message, those links are uploaded and the message is sent once more. MAX
|
|
825
|
+
* processes video/file uploads asynchronously — sendMessage may answer
|
|
826
|
+
* attachment.not.ready for a few seconds; retry the send (not the upload).
|
|
827
|
+
* Audio goes as MAX's audio attachment (shown as a voice message); if MAX
|
|
828
|
+
* refuses it, the audio is sent once more as a file, so the reply is not lost.
|
|
575
829
|
*/
|
|
576
830
|
async function sendMaxAttachmentsMessage(
|
|
577
831
|
api: MaxApi,
|
|
578
832
|
to: string,
|
|
579
833
|
caption: string,
|
|
580
|
-
sources:
|
|
834
|
+
sources: MaxMediaSource[],
|
|
581
835
|
opts: MaxMediaSendOptions,
|
|
582
836
|
): Promise<MaxSendOutcome> {
|
|
837
|
+
const links = await Promise.all(sources.map(isPublicImageLink));
|
|
838
|
+
// Loaded once per message: a resend reuses the bytes instead of a new read.
|
|
839
|
+
const loaded = new Map<number, Promise<MaxLoadedMedia>>();
|
|
840
|
+
const asVoice = opts.asVoice === true && sources.length === 1;
|
|
841
|
+
let audioAsFile = false;
|
|
842
|
+
let hasAudio = false;
|
|
583
843
|
const build = async (allowLinks: boolean): Promise<MaxNewMessageBody> => {
|
|
584
844
|
const attachments: MaxAttachment[] = [];
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
845
|
+
hasAudio = false;
|
|
846
|
+
for (const [index, source] of sources.entries()) {
|
|
847
|
+
if (allowLinks && links[index]) {
|
|
848
|
+
attachments.push({ type: 'image', payload: { url: source as string } });
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
let media = loaded.get(index);
|
|
852
|
+
if (!media) {
|
|
853
|
+
media = loadMaxMediaSource(api, source, opts);
|
|
854
|
+
loaded.set(index, media);
|
|
855
|
+
}
|
|
856
|
+
const loadedMedia = await media;
|
|
857
|
+
const type = maxUploadType(loadedMedia, asVoice);
|
|
858
|
+
const attachment = await uploadMaxAttachment(
|
|
859
|
+
api,
|
|
860
|
+
loadedMedia,
|
|
861
|
+
type === 'audio' && audioAsFile ? 'file' : type,
|
|
590
862
|
);
|
|
863
|
+
if (attachment.type === 'audio') hasAudio = true;
|
|
864
|
+
attachments.push(attachment);
|
|
591
865
|
}
|
|
592
866
|
if (opts.buttons?.length) {
|
|
593
867
|
attachments.push(buildInlineKeyboard(opts.buttons) as unknown as MaxAttachment);
|
|
@@ -603,12 +877,25 @@ async function sendMaxAttachmentsMessage(
|
|
|
603
877
|
retry: retryWhileAttachmentNotReady,
|
|
604
878
|
});
|
|
605
879
|
|
|
606
|
-
if (!
|
|
880
|
+
if (!links.some(Boolean)) {
|
|
881
|
+
try {
|
|
882
|
+
return await send(false);
|
|
883
|
+
} catch (err) {
|
|
884
|
+
if (!hasAudio || !isMaxAudioRefusal(err)) throw err;
|
|
885
|
+
getMaxLogger().warn(
|
|
886
|
+
`[MAX] message with audio refused (${(err as MaxApiError).code ?? 400}); sending the audio as a file`,
|
|
887
|
+
);
|
|
888
|
+
audioAsFile = true;
|
|
889
|
+
return send(false);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
607
892
|
try {
|
|
608
893
|
return await send(true);
|
|
609
894
|
} catch (err) {
|
|
610
895
|
if (!(err instanceof MaxApiError)) throw err;
|
|
611
|
-
|
|
896
|
+
getMaxLogger().warn(
|
|
897
|
+
`[MAX] image by URL refused (${err.code ?? err.status}); uploading instead`,
|
|
898
|
+
);
|
|
612
899
|
return send(false);
|
|
613
900
|
}
|
|
614
901
|
}
|
|
@@ -617,13 +904,13 @@ async function sendMaxAttachmentsMessage(
|
|
|
617
904
|
* Send a media message to MAX (with upload).
|
|
618
905
|
* @param to Chat ID or user ID
|
|
619
906
|
* @param caption Text caption
|
|
620
|
-
* @param mediaPath Local file path
|
|
907
|
+
* @param mediaPath Local file path (under the allowed roots), URL (public https image links are sent by URL) or inline content
|
|
621
908
|
* @param opts Send options
|
|
622
909
|
*/
|
|
623
910
|
export async function sendMaxMediaMessage(
|
|
624
911
|
to: string,
|
|
625
912
|
caption: string,
|
|
626
|
-
mediaPath:
|
|
913
|
+
mediaPath: MaxMediaSource,
|
|
627
914
|
opts: MaxMediaSendOptions = {},
|
|
628
915
|
): Promise<MaxSendOutcome> {
|
|
629
916
|
const token = resolveToken(opts);
|
|
@@ -636,9 +923,9 @@ export async function sendMaxMediaMessage(
|
|
|
636
923
|
* (up to 12 per message, an album), audio and files one per message. Order is
|
|
637
924
|
* kept.
|
|
638
925
|
*/
|
|
639
|
-
export function groupMaxMedia(sources:
|
|
640
|
-
const groups:
|
|
641
|
-
let album:
|
|
926
|
+
export function groupMaxMedia<T extends MaxMediaSource>(sources: T[]): T[][] {
|
|
927
|
+
const groups: T[][] = [];
|
|
928
|
+
let album: T[] = [];
|
|
642
929
|
const flush = () => {
|
|
643
930
|
if (album.length) groups.push(album);
|
|
644
931
|
album = [];
|
|
@@ -666,8 +953,8 @@ export function groupMaxMedia(sources: string[]): string[][] {
|
|
|
666
953
|
export async function sendMaxMediaGroup(
|
|
667
954
|
to: string,
|
|
668
955
|
caption: string,
|
|
669
|
-
sources:
|
|
670
|
-
opts: MaxMediaSendOptions & { onError?: (err: unknown, sources:
|
|
956
|
+
sources: MaxMediaSource[],
|
|
957
|
+
opts: MaxMediaSendOptions & { onError?: (err: unknown, sources: MaxMediaSource[]) => void } = {},
|
|
671
958
|
): Promise<{ messageIds: string[] }> {
|
|
672
959
|
const token = resolveToken(opts);
|
|
673
960
|
const api = new MaxApi({ token });
|